Skip to main content

Buy a Label

POST/api/v1/shipments/{shipmentId}/labels Open label purchase in the API Reference.

Purchase a Label for a Shipment using a Rate identifier returned by Get Shipping Rates. Use Select a Rate to make the application-owned choice. The request body carries only the selected Rate's nested rate.id:

{ "rate": { "id": "c4a1b2d3-..." } }

Three inputs, three different places in the request — this is the step where they are easiest to mix up:

  • shipmentId goes in the path and identifies which Shipment the Label is for. It came from Order creation.
  • Authorization: Bearer <access token> goes in a header and is required for every protected public operation. X-SubAccount-Id is an optional header whose effect depends on the operation.
  • The selected Rate goes in the body, as a nested rate object carrying its id. It came from Get Rates. There is no flat rate.id key, and the request takes no label-format input.

A 200 does not return a standalone Label. data is the Shipment, and the Label is nested inside it: retain data.postageLabel.id as your labelId for downloading or cancelling the Label. See Response below.

curl -s -X POST "${MAILHUB_API_BASE_URL}/api/v1/shipments/${SHIPMENT_ID}/labels" \
-H "Authorization: Bearer ${MAILHUB_ACCESS_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"rate": {"id": "'"${SELECTED_RATE_ID}"'"}}'

Prerequisites​

The Order must not already be Cancelled, already LabelCreated (a Label was already purchased for it), or Returned — any other status, including a brand-new Created Order, is accepted. A successful purchase automatically moves the Order to LabelCreated — you do not PATCH the status yourself for this transition. See Update Order Status for the full transition table.

Response​

A 200 means the purchase has completed. data is the Shipment: its root data.id is the Shipment's own identifier, not a Label. Take the nested data.postageLabel.id — that is the value Download a Label and Cancel a Label accept.

{
"success": true,
"data": {
"id": "b91a2c3d-...",
"status": "Unknown",
"trackingCode": "9400111899223197428490",
"orderId": "5f2c1a10-...",
"selectedRate": {
"id": "c4a1b2d3-...",
"carrier": "USPS",
"service": "<carrier-specific service code>",
"rate": 8.42,
"currency": "USD"
},
"postageLabel": {
"id": "f6c7d8e9-...",
"labelProcessingStatus": "processing",
"labelUrl": "",
"labelPdfUrl": null,
"labelZpl203Url": null,
"labelZpl300Url": null,
"labelZpl600Url": null,
"labelFileType": "pdf",
"labelAssets": [
{"format": "PDF", "status": "processing", "url": null},
{"format": "ZPL300DPI", "status": "processing", "url": null},
{"format": "ZPL203DPI", "status": "processing", "url": null},
{"format": "ZPL600DPI", "status": "processing", "url": null}
]
},
"createdAt": "...",
"updatedAt": "..."
},
"errors": null
}

selectedRate echoes the Rate this purchase used. Its service field carries that Rate's serviceCode value, not the human-readable serviceName — see Rates. The request itself carries no service field: it identifies the Rate by rate.id alone.

Per-format label assets​

postageLabel.labelAssets[] carries one entry per printable format — PDF, ZPL300DPI, ZPL203DPI, and ZPL600DPI — each with its own readiness:

FieldMeaning
formatThe printable format this entry describes.
status"completed" when the entry carries a url, "processing" when it does not.
urlA link to the file, or null when no link is currently available.

These are the same links the flat labelPdfUrl, labelZpl203Url, labelZpl300Url, and labelZpl600Url fields carry; labelAssets[] is the format-keyed view of the same set.

Each url is a temporary pre-signed storage link. It downloads the file directly with no Authorization header and it expires — treat it as a credential rather than a durable address, and do not store, log, or share it. There is no refresh operation and re-purchasing is never required: read the Order again with Get Order (linked under When the purchase is still running) for fresh links, under shipment.labelAssets[]. That response publishes the array only — it carries neither labelProcessingStatus nor the flat label URL fields.

A "processing" entry records that no link is available right now. Nothing in the public contract states when an asset completes, or guarantees that it will. Download a Label remains the documented retrieval path and takes your bearer credential.

When the purchase is still running​

The operation waits for the purchase for a bounded window. If the purchase is still running when that window ends, it answers 202 instead of holding the request open. The purchase continues server-side, so a 202 you received is not a failure and does not ask for another purchase request: read the Order to observe how it ends rather than re-sending the POST as a way to poll. Repeating the request belongs to a different case — an outcome you never saw — and is covered under Repeating the request.

{
"success": true,
"data": {
"operationId": "9a8b7c6d-...",
"shipmentId": "b91a2c3d-...",
"rateId": "c4a1b2d3-...",
"status": "Processing",
"correlationId": "..."
},
"errors": null
}

rateId echoes the rate.id you submitted. None of these fields is one you send back — they identify the running purchase for your own logging, not a resource you can fetch.

Observe the outcome by retrieving the Order — Get Order returns labelId once the purchase has completed, and its status moves to LabelCreated. That labelId is the same identifier a 200 returns as postageLabel.id. There is no operation-status endpoint to poll, and operationId above is diagnostic only — you never send it back. Choose your own polling cadence; the contract defines no interval.

Repeating the request​

This is for the ambiguous case: a timeout, a dropped connection, or any response you cannot attribute to success or failure, where you do not know whether the original request ever reached Mailhub. A 202 you did receive is not that case — it already told you the purchase is running, and the Order is where you watch it finish.

The request carries no idempotency key. Instead, Mailhub recognizes a repeat server-side from the Shipment's purchase operation and the request that started it: sending the same purchase request again does not buy a second Label. A repeat of the same request replays the original outcome — 200 with the original result once it has completed, 202 while it is still running.

A request that is not the same purchase returns 409 rather than starting a competing one: a different rate for a Shipment whose purchase is in progress, a Shipment that already has a Label, or an earlier purchase whose carrier outcome is still being resolved. Correct the request or read the Order; do not loop on a 409.

Two bounded conditions remain, so this is safe repetition rather than a blanket guarantee. Retry within your own bounded budget:

  • If the Label bought by your request was cancelled before the result reached you, the request is answered as a conflict and will not purchase a replacement. Submit a new purchase if you still need one.
  • An unresolved carrier outcome is reconciled server-side. Until it settles, a new purchase for that Shipment is refused with 409.

See Retries and Ambiguous Outcomes for the cross-operation policy.

Errors​

400 for an invalid or no-longer-usable rate id. 401 for a missing or expired bearer token. 409 when a different purchase is in progress or the Shipment already has a Label. See Label Errors.

For the shortest working version of this purchase, see Buy a Label from a Selected Rate.

After purchase, continue to Download a Label. When your application needs the documented cancellation operation, see Cancel a Label. Working with USPS? See USPS with Mailhub.