Labels
The full label lifecycle: turn a rate into a printed shipping label, fetch its current state, list your account's history, and refund unprinted ones.
The label lifecycle
Every label moves through a small state machine. Most integrations only need to react to purchased and failed; the intermediate states exist so you can surface progress UI to your own customers.
paying— buy request accepted, payment + carrier buy in flight. Typical duration: 2–10 seconds.purchased— label PDF is available atlabelUrl,trackingNumberis set, the carrier has confirmed the purchase.in_transit— first carrier tracking scan has landed; the package has left your hands.delivered— terminal success state. Tracking shows the final delivery scan.exception— carrier flagged a problem (lost, damaged, address undeliverable). Not terminal — may resolve back toin_transit.voided— label refunded before it was scanned. Terminal.failed— the carrier rejected the purchase or payment failed. Terminal.
Create a label
/api/v1/labels/createScope: write:shipmentsBuilds a shipment, picks a rate per your service preference, and buys the label — all in one call. Returns 202 with the new label's id while the carrier processes the purchase asynchronously.
This endpoint fetches live carrier rates internally and picks one based on the serviceCategory / carrier / service hints you supply (defaults to the cheapest overall). If you want to see rates before committing to a buy, call POST /labels/quote first and inspect the response — but pass the same address + parcel back here when you're ready to buy; we don't accept rate ids across requests because carrier rates expire quickly.
Request body
fromAddressIdstringID of a saved sender address. EitherfromAddressIdorfromAddressis required.fromAddressobjectInline sender address. Shape matchestoAddressbelow; use this when the sender isn't a saved address.toAddressobjectrequiredRecipient. Shape:{ street1, street2?, city, state, zip, country, name?, company?, phone?, email?, isResidential? }.countryis ISO 3166 alpha-2.parcelobjectrequiredPackage dimensions. Shape:{ lengthIn, widthIn, heightIn, weightOz, predefinedPackage? }.predefinedPackageis the carrier-pak code (e.g.MediumFlatRateBox) when shipping a flat-rate container.serviceCategorystringOne ofstandard,express,overnight,international. We pick the cheapest rate inside the category. Omit to pick the cheapest rate overall.carrierstringConstrain to one ofUSPS,UPS,FedEx,DHL.servicestringMatch a specific carrier service name (e.g.PriorityMailExpress). Combined withcarrierfor an exact pick.optionsobjectAdd-ons. Shape:{ insuredValueCents?, signatureConfirmation?, adultSignatureRequired? }.insuredValueCentsopts in to carrier-backed insurance for the declared amount; billing reflects the tier-matched premium on top of the rate.customsobjectRequired whentoAddress.countryisn'tUS. Shape:{ contentsDescription, declaredValueCents, hsTariffNumber? }. We snapshot the HS code on the shipment so a customs dispute has an audit trail.idempotencyKeystringOptional but strongly recommended. Use your stable customer-order id; duplicate calls with the same key return the same shipment instead of double-charging. 8–64 characters, ASCII. Defaults to a server-generated key tied to this shipment id if omitted.
Response (202 Accepted)
idstringYour label's identifier in our system. Prefixedlbl_….statusstringInitial status — almost alwayspayingin the 202 response. Poll or webhook for the transition topurchased.carrierstringCarrier name snapshot from the rate.servicestringService name snapshot from the rate.trackingNumberstring | nullnull until status flips to purchased.labelUrlstring | nullnull until the rehosted PDF lands in our storage; then a stable URL on our domain (e.g.https://shiponline.app/api/v1/labels/{id}/label.pdf). Not a presigned URL — auth is checked on every fetch, so the URL stays valid until you revoke the token.chargeAmountCentsintegerTotal charged to your account — same as the rate's customerPriceCents.platformFeeCentsintegerExplicit $0.08 API fee broken out.sourcestringAlwaysapifor labels created via this endpoint.insuredDeclaredValueCentsintegerEchoed back fromoptions.insuredValueCentswhen insurance was requested.0otherwise.insuranceFeeCentsintegerThe premium we charged for insurance, computed from the tier table at buy time.0when insurance wasn't requested.contentsDescriptionstring | nullEchoed back from customs.contentsDescription when international.declaredValueCentsinteger | nullEchoed back from customs.declaredValueCents when international.hsTariffNumberstring | nullEchoed back fromcustoms.hsTariffNumberwhen supplied. The raw input is stored (and forwarded to the carrier with separators stripped).
Example
curl -X POST https://shiponline.app/api/v1/labels/create \
-H "Authorization: Bearer shp_live_..." \
-H "Content-Type: application/json" \
-d '{
"fromAddressId": "adr_AAA...",
"toAddress": {
"street1": "417 Montgomery St",
"city": "San Francisco",
"state": "CA",
"zip": "94104",
"country": "US",
"name": "Ada Lovelace"
},
"parcel": {
"lengthIn": 9,
"widthIn": 6,
"heightIn": 3,
"weightOz": 14
},
"serviceCategory": "standard",
"idempotencyKey": "order-12345"
}'202 doesn't mean done
shipment.purchased webhook or poll the label until its status flips. Most integrations resolve within 5 seconds.Get a label
/api/v1/labels/{id}Scope: read:shipmentsFetch the current state of a label by id. Includes the label PDF URL + tracking number once status is purchased.
Use this to poll for completion if you cannot accept webhooks in your environment (e.g. a shell script, a serverless function without a public ingress). Recommended polling interval: 1 second for the first 10 seconds, then back off to 5 seconds.
curl https://shiponline.app/api/v1/labels/lbl_CCC... \
-H "Authorization: Bearer shp_live_..."Response (200 OK)
Same shape as the POST /labels/create 202 response. Once status is purchased, trackingNumber and labelUrl are populated; before that they are null.
List labels
/api/v1/labelsScope: read:shipmentsPaginated list of your account's labels, newest first.
Query parameters
limitintegerPage size, 1–100. Default 25.cursorstringOpaque pagination cursor returned by the previous response asnextCursor. Omit for the first page.statusstringFilter by label status. One ofpaying,purchased,in_transit,delivered,exception,voided,failed.
Response (200 OK)
{
"data": [
{
"id": "lbl_CCC...",
"status": "purchased",
"carrier": "USPS",
"service": "Priority",
"trackingNumber": "9405511...",
"labelUrl": "https://shiponline.app/api/v1/labels/lbl_CCC.../label.pdf",
"createdAt": "2026-06-26T12:14:00Z"
}
],
"pagination": {
"hasMore": true,
"nextCursor": "lbl_BBB..."
}
}pagination.nextCursor is nullwhen you've reached the last page. pagination.hasMore is false on the same response. See Pagination for the full cursor semantics.
Download the label PDF
/api/v1/labels/{id}/label.pdfScope: read:shipmentsStream the rehosted label PDF. Same URL as the labelUrl field on the GET /labels/{id} response. Returns the carrier-native 4x6 PDF; auth is checked on every request so the URL stays valid until you revoke the token.
The labelUrlfield on the GET response points at this endpoint. It's not a presigned URL — your Bearer token is checked on every fetch, which means the URL stays valid until you revoke the token, and we can revoke access mid-stream (account closure, suspension).
Two response shapes other than 200:
202 label_pending— the worker hasn't finished rehosting from the carrier yet. Retry in a few seconds.404 label_unavailable— the rehosted object isn't in our storage (shouldn't happen for healthy labels; contact support).
Refund a label
/api/v1/labels/{id}/refundScope: write:shipmentsRequest a refund on a purchased label. The carrier confirms voids within 1-3 business days; refunds land on your shipping credit balance.
Voiding a label removes it from the carrier's active manifest and refunds the carrier rate to your account. You can only void labels that:
- Are in the
purchasedstate (orin_transitif the first scan happened in error). - Have not been physically scanned by the carrier — once a real scan lands, the void is rejected with
422 already_in_transit. - Were created less than 28 days ago.
Request body
Empty body. The label id in the URL is the only required identifier.
Response (202 Accepted)
{
"id": "lbl_CCC...",
"status": "voided",
"refundStatus": "pending",
"refundAmountCents": 828
}refundStatus is pending until the carrier confirms (1–3 business days). When it flips to refunded the carrier-rate portion lands on your shipping credit balance and auto-applies to your next label purchase. The platform fee portion ($0.08) is non-refundable.
Error cases
422 invalid_request— input validation failed. Response includesissues[]with the field path(s).422 no_rates— the carriers returned no rates for the supplied origin/destination/parcel. Check the recipient address.422 no_matching_service— the carriers returned rates but none matched yourserviceCategory/carrier/servicehint. Response includesavailableServices[].409 already_voided— refund attempted on a label already in a terminal void state.402 no_payment_method— no default card on the account. The response includes a one-timepayLinkUrl— redirect your buyer to that Stripe- hosted page, they fill in a card or bank account, then retry the label call with the same idempotency key.
Headless first-buy recovery
When the buyer has no payment method on file, the 402 response includes a hosted Stripe Checkout URL so you can collect a card without sending your user through our dashboard:
{
"code": "no_payment_method",
"error": "No default payment method on file.",
"payLinkUrl": "https://checkout.stripe.com/c/pay/cs_test_…",
"payLinkExpiresAt": 1735689600
}Redirect the buyer to payLinkUrl. Stripe captures the card on their hosted UI, attaches it to the buyer's customer record, and redirects back to /billing/setup-complete. After the redirect, retry the original POST with the same Idempotency-Key header — the second call goes straight through.
Need a setup link without hitting 402 first (e.g. you want to prompt the buyer up front)? POST /v1/billing/setup-link mints one on demand. Optional returnUrl / cancelUrl body params let you host your own success page instead of ours.
See Errors for the full status-code reference.