Labels API — Documentation for Clients
REST API for purchasing UPS shipping labels.
- Base URL:
https://api.easyship.lol(provided per client) - Authentication: API Key (Bearer token in
Authorizationheader) - Format: JSON request/response, UTF-8
- Versioning: all endpoints under
/api/v1/
Important: an API key is issued personally for each client. Keep it secret — it has direct access to your prepaid balance. If the key is leaked, contact support immediately to regenerate it.
Quick Start
# 1. Check balance
curl -H "Authorization: Bearer YOUR_API_KEY" \
https://api.easyship.lol/api/v1/balance
# 2. Buy a label (returns order_id + tracking + price)
curl -X POST https://api.easyship.lol/api/v1/orders \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d @order.json
# 3. Download the label PDF
curl -H "Authorization: Bearer YOUR_API_KEY" \
-o label.pdf \
https://api.easyship.lol/api/v1/orders/123/label
Authentication
Every request must include the header:
Authorization: Bearer <YOUR_API_KEY>
Keys look like: lk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
On 401 Unauthorized — the key is invalid, deactivated, or missing.
Pricing
- The final price for each label is returned in the
pricefield of the order response. There is no separate breakdown — only the amount you will be charged. - Pricing depends on package weight, dimensions, and destination.
- Each label is charged from your prepaid balance the moment it's purchased.
- Minimum balance: $10.00. You cannot create new orders if your balance is below this threshold. Refunds credit back to your balance and count toward the minimum.
- If your balance is insufficient when you try to buy — the request is rejected with
402 Payment Required(see Errors).
To top up your balance — contact support.
Endpoints
GET /api/v1/healthz
Liveness probe. No authentication required.
curl https://api.easyship.lol/api/v1/healthz
Response 200:
{ "ok": true, "service": "labels-api", "version": "0.1.0" }
GET /api/v1/balance
Returns the current balance of the authenticated client.
curl -H "Authorization: Bearer YOUR_API_KEY" \
https://api.easyship.lol/api/v1/balance
Response 200:
{
"client": "Acme Inc",
"balance": 88.98,
"currency": "USD"
}
POST /api/v1/orders
Creates an order and immediately purchases the label from UPS. Returns the order id, tracking number, and final price.
curl -X POST https://api.easyship.lol/api/v1/orders \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d @order.json
Request body:
{
"ship_from": {
"name": "John Sender",
"company": "Acme Inc",
"address1": "1600 Amphitheatre Pkwy",
"address2": "Suite 200",
"city": "Mountain View",
"state": "CA",
"zip": "94043",
"country": "US",
"phone": "5555555555"
},
"ship_to": {
"name": "Jane Receiver",
"address1": "350 Fifth Avenue",
"city": "New York",
"state": "NY",
"zip": "10118",
"country": "US",
"phone": "5555555555"
},
"package": {
"weight_lbs": 1.0,
"weight_oz": 0,
"length": 6,
"width": 6,
"height": 6
},
"service": "Ground",
"carrier": "ups",
"external_order_id": "342880",
"mule_order_id": "342880",
"shipping_id": "SHIP-12345",
"provider_id": "PROV-67890"
}
Field reference:
| Field | Required | Type | Notes |
|---|---|---|---|
ship_from.name |
yes | string | Sender full name, 1–120 chars |
ship_from.company |
no | string | Optional company name |
ship_from.address1 |
yes | string | Street address |
ship_from.address2 |
no | string | Apt / Suite / Unit |
ship_from.city |
yes | string | |
ship_from.state |
yes | string | 2-letter US state code (e.g. FL) |
ship_from.zip |
yes | string | US ZIP, 5 or 9 digits |
ship_from.country |
no | string | Defaults to US, 2-letter ISO |
ship_from.phone |
no | string | Sender phone |
ship_to.* |
same | Same shape as ship_from |
|
package.weight_lbs |
yes | number | Pounds (use 0 if ounces only) |
package.weight_oz |
no | number | Ounces, added to pounds |
package.length |
yes | number | Inches, max 108 |
package.width |
yes | number | Inches, max 108 |
package.height |
yes | number | Inches, max 108 |
service |
no | string | Ground (default), 2nd Day Air, 3 Day Select, Next Day Air, Next Day Air Saver |
carrier |
no | string | Defaults to ups; only ups supported today |
external_order_id |
no | string | Client/integration order id. Used as idempotency fallback if Idempotency-Key header is missing. |
mule_order_id |
no | string | MuleClub alias for external_order_id; accepted for compatibility and echoed back in responses. |
shipping_id |
no | string | MuleClub Shipping ID / download-job id, stored for lookup/debugging. |
provider_id |
no | string | MuleClub Provider ID, stored for lookup/debugging. |
Total weight =
weight_lbs * 16 + weight_oz(in ounces). Must be ≥ 1 oz.
Response 201 Created:
{
"order_id": 123,
"ownerlabels_order_id": 123,
"status": "purchased",
"tracking_code": "1Z19D2C70325572916",
"tracking_number": "1Z19D2C70325572916",
"tracking_url": "https://www.ups.com/track?tracknum=1Z19D2C70325572916",
"external_order_id": "342880",
"mule_order_id": "342880",
"shipping_id": "SHIP-12345",
"provider_id": "PROV-67890",
"price": 12.34,
"label_url": "/api/v1/orders/123/label",
"pdf_url": "/api/v1/orders/123/label",
"error": null
}
The label is ready immediately — fetch it with GET /api/v1/orders/123/label.
External IDs are echoed back in create/status responses so MuleClub can map:
mule_order_id → shipping_id → provider_id → ownerlabels_order_id → tracking_number → label_url
GET /api/v1/orders/{order_id}
Returns the current status of an order. Useful for re-checking an old order or polling if you lost the original response.
curl -H "Authorization: Bearer YOUR_API_KEY" \
https://api.easyship.lol/api/v1/orders/123
Response 200:
{
"order_id": 123,
"ownerlabels_order_id": 123,
"status": "purchased",
"tracking_code": "1Z19D2C70325572916",
"tracking_number": "1Z19D2C70325572916",
"tracking_url": "https://www.ups.com/track?tracknum=1Z19D2C70325572916",
"external_order_id": "342880",
"mule_order_id": "342880",
"shipping_id": "SHIP-12345",
"provider_id": "PROV-67890",
"price": 12.34,
"label_url": "/api/v1/orders/123/label",
"pdf_url": "/api/v1/orders/123/label",
"error": null
}
Lookup by external IDs:
curl -H "Authorization: Bearer YOUR_API_KEY" \
https://api.easyship.lol/api/v1/orders/by-external/342880
curl -H "Authorization: Bearer YOUR_API_KEY" \
https://api.easyship.lol/api/v1/orders/by-shipping/SHIP-12345
curl -H "Authorization: Bearer YOUR_API_KEY" \
https://api.easyship.lol/api/v1/orders/by-provider/PROV-67890
Each endpoint returns the same OrderResponse shape as GET /api/v1/orders/{order_id}.
Order statuses:
| Status | Meaning |
|---|---|
pending |
Order created but the label purchase hasn't completed (transient — rare) |
purchased |
Label is ready to download |
failed |
Purchase failed; check the error field. No charge has been applied. |
GET /api/v1/orders/{order_id}/label
Downloads the label as a PDF file (Content-Type: application/pdf).
curl -H "Authorization: Bearer YOUR_API_KEY" \
-o label_123.pdf \
https://api.easyship.lol/api/v1/orders/123/label
- Response body is the raw PDF binary.
- Browser-friendly:
Content-Disposition: attachment; filename=label_<tracking>.pdf. - Only returns the file when
status == "purchased".
POST /api/v1/orders/{order_id}/refund
Requests a refund for a previously-purchased label. The full price you paid is credited back to your balance (including our fee). Refunds are typically instant for UPS — your balance reflects the credit immediately.
curl -X POST -H "Authorization: Bearer *** \
https://api.easyship.lol/api/v1/orders/123/refund
Response (instant refund — UPS):
{
"order_id": 123,
"refund_status": "refunded",
"refund_amount": 12.84,
"requested_at": 1781205198,
"refunded_at": 1781205198,
"estimated_refund_date": null,
"message": "Refund processed instantly. Balance credited."
}
Response (pending — non-instant carriers):
{
"order_id": 123,
"refund_status": "requested",
"refund_amount": null,
"requested_at": 1781205198,
"refunded_at": null,
"estimated_refund_date": "2026-06-25T19:12:17+00:00",
"message": "Refund requested. Carrier will confirm within ~14 days. Balance will be credited automatically."
}
Rules:
- Refund can be requested only after 10 days from the original purchase date. Before that the endpoint returns
409with the exact date when refund becomes available. - The label must not have been scanned/used by the carrier. If the carrier rejects the refund (label already in transit, etc.), you'll get
409with the carrier's explanation. - A refund can only be requested once per order. Calling
/refunda second time returns the current state (no duplicate processing, no double-credit). - Once
refund_status == "refunded"the credit is on your balance — verify withGET /balance. - For pending refunds (rare on UPS, common on other carriers), poll
GET /api/v1/orders/{id}untilrefund_statusbecomesrefundedordenied. The system polls the carrier every 15 minutes and credits your balance automatically when confirmed.
Checking refund status later — the existing GET /api/v1/orders/{id} response now includes refund fields:
{
"order_id": 123,
"status": "purchased",
"tracking_code": "1Z2X8C700323237900",
"price": 12.84,
...
"refund_status": "refunded",
"refund_amount": 12.84,
"refund_requested_at": 1781205198,
"refunded_at": 1781205198
}
refund_status is one of: none (never requested), requested (waiting on carrier), refunded (done — balance credited), denied (carrier rejected — see error field or contact support).
Errors
All errors are returned as JSON with HTTP status codes following standard REST conventions:
{ "detail": "Human-readable error message" }
| HTTP | Meaning | What to do |
|---|---|---|
400 Bad Request |
Malformed JSON or missing fields | Check the request schema |
401 Unauthorized |
Missing or invalid API key | Verify the Authorization header |
402 Payment Required |
Your balance is below the $10 minimum, or too low for this label | Top up your balance |
404 Not Found |
Order id doesn't exist (or doesn't belong to your account) | Check the id |
409 Conflict |
Label not yet ready (status ≠ purchased) |
Poll the order endpoint |
410 Gone |
Label file is missing on our storage | Contact support |
422 Unprocessable Entity |
Validation failed — bad ZIP, weight too low, invalid service, etc. | Read detail, fix and retry |
429 Too Many Requests |
Rate-limit (not enforced yet, future) | Slow down |
502 Bad Gateway |
Carrier (UPS) refused the shipment for a reason we couldn't categorize | Read detail; usually an address issue. Retry won't help — fix and re-create the order. |
503 Service Unavailable |
We can't reach the carrier right now (auth/network outage on our side) | Retry in 1–2 minutes |
Examples:
// 401
{ "detail": "Invalid API key" }
// 402
{ "detail": "Insufficient balance: requires $12.34, you have $5.40" }
// 422
{ "detail": "Service 'ups Overnight' not available for this shipment" }
// 422
{ "detail": "Package weight too small (need ≥1 oz)" }
// 503
{ "detail": "Upstream provider unavailable. Try again later." }
Best Practices
- Save the
order_idas soon as you receive it — it's the only way to re-download the label later. - Don't retry on 4xx errors except
429. They mean your request is wrong, retrying won't help. Fix and re-submit. - Do retry on 503 with exponential backoff (1s, 2s, 5s, 15s).
- Validate addresses on your side before sending — typos in ZIP / state are the #1 cause of 422 / 502.
- Store the PDF after the first successful download. Re-fetching is supported but not guaranteed forever.
- Monitor your balance — call
/balancedaily; we'll add webhooks for low-balance alerts later.
Support
- Issues, key rotation, top-ups: contact your account manager
- Status page: TBD
Changelog
- 2026-06-11 — v0.2.1 — Minimum balance of $10 required to create orders.
- 2026-06-11 — v0.2.0 — Added refunds. POST
/api/v1/orders/{id}/refund(instant on UPS, 10-day window). GET/orders/{id}now includes refund fields. - 2026-05-29 — v0.1.0 — Initial release. UPS Ground only, single carrier, prepaid balance.