Resources

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 at labelUrl, trackingNumber is 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 to in_transit.
  • voided — label refunded before it was scanned. Terminal.
  • failed — the carrier rejected the purchase or payment failed. Terminal.

Create a label

POST/api/v1/labels/createScope: write:shipments

Builds 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

  • fromAddressIdstring
    ID of a saved sender address. Either fromAddressId or fromAddress is required.
  • fromAddressobject
    Inline sender address. Shape matches toAddressbelow; use this when the sender isn't a saved address.
  • toAddressobjectrequired
    Recipient. Shape: { street1, street2?, city, state, zip, country, name?, company?, phone?, email?, isResidential? }. country is ISO 3166 alpha-2.
  • parcelobjectrequired
    Package dimensions. Shape: { lengthIn, widthIn, heightIn, weightOz, predefinedPackage? }. predefinedPackage is the carrier-pak code (e.g. MediumFlatRateBox) when shipping a flat-rate container.
  • serviceCategorystring
    One of standard, express, overnight, international. We pick the cheapest rate inside the category. Omit to pick the cheapest rate overall.
  • carrierstring
    Constrain to one of USPS, UPS, FedEx, DHL.
  • servicestring
    Match a specific carrier service name (e.g. PriorityMailExpress). Combined with carrier for an exact pick.
  • optionsobject
    Add-ons. Shape: { insuredValueCents?, signatureConfirmation?, adultSignatureRequired? }. insuredValueCents opts in to carrier-backed insurance for the declared amount; billing reflects the tier-matched premium on top of the rate.
  • customsobject
    Required when toAddress.country isn't US. Shape: { contentsDescription, declaredValueCents, hsTariffNumber? }. We snapshot the HS code on the shipment so a customs dispute has an audit trail.
  • idempotencyKeystring
    Optional 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)

  • idstring
    Your label's identifier in our system. Prefixed lbl_….
  • statusstring
    Initial status — almost always paying in the 202 response. Poll or webhook for the transition to purchased.
  • carrierstring
    Carrier name snapshot from the rate.
  • servicestring
    Service name snapshot from the rate.
  • trackingNumberstring | null
    null until status flips to purchased.
  • labelUrlstring | null
    null 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.
  • chargeAmountCentsinteger
    Total charged to your account — same as the rate's customerPriceCents.
  • platformFeeCentsinteger
    Explicit $0.08 API fee broken out.
  • sourcestring
    Always api for labels created via this endpoint.
  • insuredDeclaredValueCentsinteger
    Echoed back from options.insuredValueCents when insurance was requested. 0 otherwise.
  • insuranceFeeCentsinteger
    The premium we charged for insurance, computed from the tier table at buy time. 0when insurance wasn't requested.
  • contentsDescriptionstring | null
    Echoed back from customs.contentsDescription when international.
  • declaredValueCentsinteger | null
    Echoed back from customs.declaredValueCents when international.
  • hsTariffNumberstring | null
    Echoed back from customs.hsTariffNumber when 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

A 202 response means we accepted the buy request and are processing it. The label PDF is not yet available at this point — you must wait for the shipment.purchased webhook or poll the label until its status flips. Most integrations resolve within 5 seconds.

Get a label

GET/api/v1/labels/{id}Scope: read:shipments

Fetch 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
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

GET/api/v1/labelsScope: read:shipments

Paginated list of your account's labels, newest first.

Query parameters

  • limitinteger
    Page size, 1–100. Default 25.
  • cursorstring
    Opaque pagination cursor returned by the previous response as nextCursor. Omit for the first page.
  • statusstring
    Filter by label status. One of paying, purchased, in_transit, delivered, exception, voided, failed.

Response (200 OK)

JSON
{
  "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

GET/api/v1/labels/{id}/label.pdfScope: read:shipments

Stream 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

POST/api/v1/labels/{id}/refundScope: write:shipments

Request 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:

  1. Are in the purchased state (or in_transit if the first scan happened in error).
  2. Have not been physically scanned by the carrier — once a real scan lands, the void is rejected with 422 already_in_transit.
  3. 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)

JSON
{
  "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 includes issues[] 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 your serviceCategory / carrier / service hint. Response includes availableServices[].
  • 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-time payLinkUrl — 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.

Stuck or shipping production?

We read every developer email.

API questions, OAuth quirks, custom-flow integration help — email support@shiponline.app and a human replies, usually within a few hours.

  • No card to start
  • Labels in 60 seconds
  • Up to 70% off retail