Buy a Label
/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:
shipmentIdgoes 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-Idis an optional header whose effect depends on the operation.- The selected Rate goes in the body, as a nested
rateobject carrying itsid. It came from Get Rates. There is no flatrate.idkey, 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
- JavaScript
- C#
- Python
- Java
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}"'"}}'
const labelResponse = await fetch(`${process.env.MAILHUB_API_BASE_URL}/api/v1/shipments/${shipmentId}/labels`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({rate: {id: selectedRateId}}),
});
if (labelResponse.status === 202) {
// Still running. Poll GET /api/v1/orders/{orderId} for the outcome.
return;
}
const {data: shipment} = await labelResponse.json();
const labelId = shipment.postageLabel.id;
var labelRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/shipments/{shipmentId}/labels")
{
Content = JsonContent.Create(new { rate = new { id = selectedRateId } }),
};
labelRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var labelResponse = await client.SendAsync(labelRequest);
using var labelPayload = JsonDocument.Parse(await labelResponse.Content.ReadAsStreamAsync());
// 202 means the purchase is still running; read the Order for the outcome.
var labelId = labelResponse.StatusCode == HttpStatusCode.Accepted
? null
: labelPayload.RootElement
.GetProperty("data")
.GetProperty("postageLabel")
.GetProperty("id")
.GetString();
import requests
label_response = requests.post(
f"{api_base_url}/api/v1/shipments/{shipment_id}/labels",
headers={"Authorization": f"Bearer {access_token}"},
json={"rate": {"id": selected_rate_id}},
)
if label_response.status_code == 202:
# Still running. Poll GET /api/v1/orders/{orderId} for the outcome.
label_id = None
else:
shipment = label_response.json()["data"]
label_id = shipment["postageLabel"]["id"]
// Java 11+ java.net.http — no Mailhub package to install and no third-party client.
var request = HttpRequest.newBuilder()
.uri(URI.create(apiBaseUrl + "/api/v1/shipments/" + shipmentId + "/labels"))
.header("Authorization", "Bearer " + accessToken)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(
"{\"rate\":{\"id\":\"" + selectedRateId + "\"}}"))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 202) {
// Still running. Read GET /api/v1/orders/{orderId} for the outcome.
} else {
// Parse response.body() with your own JSON library. The Label identifier is
// data.postageLabel.id — data.id is the Shipment.
}
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:
| Field | Meaning |
|---|---|
format | The printable format this entry describes. |
status | "completed" when the entry carries a url, "processing" when it does not. |
url | A 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.