Create and Download a USPS Label
Three requests take you from a sender and a recipient to a USPS Label PDF on
disk; a purchase that answers 202 adds one or more Order reads before the
download. USPS adds no endpoint of its own — this is the standard Mailhub
workflow, with the carrier chosen from what the Rates response returns.
What you'll build
- An Order with one Shipment, and the Rates for it.
- A USPS Rate, picked from the returned options by your own rule.
- A purchased Label, including the branch where the purchase outruns the response.
- A PDF file on disk.
Prerequisites
- An access token — see Authentication. Every request below sends it as a bearer token.
- Your API base URL in
MAILHUB_API_BASE_URL. The portal publishes no fixed API host; use the value supplied for your account. - US domestic addresses with two-letter state codes and ZIP or ZIP+4, and a parcel with weight and custom dimensions. See Requirements & Restrictions for the input rules Mailhub applies before a Shipment can be rated.
- Python examples use the
requestspackage.
Step 1 — Create the Order and request Rates
POST/api/v2/orders
One order object per request.
- cURL
- JavaScript
- Python
- Java
curl -s -X POST "${MAILHUB_API_BASE_URL}/api/v2/orders" \
-H "Authorization: Bearer ${MAILHUB_ACCESS_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"order": {
"fromAddress": {
"name": "Jane Sender", "street1": "123 Main St", "city": "Austin",
"state": "TX", "zip": "78701", "country": "US", "phone": "5125550100"
},
"toAddress": {
"name": "John Recipient", "street1": "456 Oak Ave", "city": "Denver",
"state": "CO", "zip": "80202", "country": "US", "phone": "3035550100"
},
"shipments": [
{"parcel": {"weight": 16, "length": 10, "width": 8, "height": 4}}
]
}
}'
const auth = {Authorization: `Bearer ${process.env.MAILHUB_ACCESS_TOKEN}`};
const base = process.env.MAILHUB_API_BASE_URL;
const orderResponse = await fetch(`${base}/api/v2/orders`, {
method: 'POST',
headers: {...auth, 'Content-Type': 'application/json'},
body: JSON.stringify({
order: {
fromAddress: {
name: 'Jane Sender', street1: '123 Main St', city: 'Austin',
state: 'TX', zip: '78701', country: 'US', phone: '5125550100',
},
toAddress: {
name: 'John Recipient', street1: '456 Oak Ave', city: 'Denver',
state: 'CO', zip: '80202', country: 'US', phone: '3035550100',
},
shipments: [{parcel: {weight: 16, length: 10, width: 8, height: 4}}],
},
}),
});
const {data} = await orderResponse.json();
const [{shipmentId, rates}] = data.shipmentRates;
import os
import requests
auth = {"Authorization": f"Bearer {os.environ['MAILHUB_ACCESS_TOKEN']}"}
base = os.environ["MAILHUB_API_BASE_URL"]
order_response = requests.post(
f"{base}/api/v2/orders",
headers=auth,
json={
"order": {
"fromAddress": {
"name": "Jane Sender", "street1": "123 Main St", "city": "Austin",
"state": "TX", "zip": "78701", "country": "US", "phone": "5125550100",
},
"toAddress": {
"name": "John Recipient", "street1": "456 Oak Ave", "city": "Denver",
"state": "CO", "zip": "80202", "country": "US", "phone": "3035550100",
},
"shipments": [
{"parcel": {"weight": 16, "length": 10, "width": 8, "height": 4}}
],
}
},
)
data = order_response.json()["data"]
shipment = data["shipmentRates"][0]
shipment_id = shipment["shipmentId"]
rates = shipment["rates"]
// Java 11+ java.net.http — no MailHub package to install and no third-party client.
String base = System.getenv("MAILHUB_API_BASE_URL");
String auth = "Bearer " + System.getenv("MAILHUB_ACCESS_TOKEN");
HttpClient client = HttpClient.newHttpClient();
String orderBody = """
{
"order": {
"fromAddress": {
"name": "Jane Sender", "street1": "123 Main St", "city": "Austin",
"state": "TX", "zip": "78701", "country": "US", "phone": "5125550100"
},
"toAddress": {
"name": "John Recipient", "street1": "456 Oak Ave", "city": "Denver",
"state": "CO", "zip": "80202", "country": "US", "phone": "3035550100"
},
"shipments": [
{"parcel": {"weight": 16, "length": 10, "width": 8, "height": 4}}
]
}
}
""";
var orderRequest = HttpRequest.newBuilder()
.uri(URI.create(base + "/api/v2/orders"))
.header("Authorization", auth)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(orderBody))
.build();
HttpResponse<String> orderResponse = client.send(
orderRequest, HttpResponse.BodyHandlers.ofString());
// Parse orderResponse.body() with your own JSON library, then keep
// data.shipmentRates[0].shipmentId and data.shipmentRates[0].rates.
Response
{
"success": true,
"data": {
"order": {"id": "5f2c1a10-2b3d-4e5f-8a9b-0c1d2e3f4a5b", "...": "..."},
"shipmentRates": [
{
"shipmentId": "b91a2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"rates": [
{
"id": "c4a1b2d3-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"carrier": "USPS",
"serviceName": "USPS Ground Advantage",
"rate": 8.42,
"currency": "USD",
"...": "..."
},
{"id": "...", "carrier": "UPS", "serviceName": "UPS Ground", "...": "..."}
]
}
]
},
"errors": null
}
Step 2 — Pick a returned USPS Rate
Read carrier to recognise USPS, and serviceName to tell the services apart.
Mailhub normalises three known names — USPS Ground Advantage,
USPS Priority Mail, and USPS Priority Mail Express — and other
provider-supplied USPS service names may still be returned. Treat an
unrecognised name as a usable option and use its Rate id.
- cURL
- JavaScript
- Python
- Java
# Selection is your application's job. With jq, take the USPS options and
# pick the service you ship with, falling back to any USPS option.
SELECTED_RATE_ID=$(printf '%s' "${RATES_JSON}" | jq -r '
[.data.shipmentRates[0].rates[] | select(.carrier == "USPS")]
| (map(select(.serviceName == $want)) + .) | .[0].id
' --arg want "USPS Ground Advantage")
// Selection is your application's job — the response does not rank the options.
const uspsRates = rates.filter(rate => rate.carrier === 'USPS');
if (uspsRates.length === 0) {
throw new Error('No USPS Rate was returned for this Shipment');
}
// Prefer the service you ship with; any returned USPS Rate is still valid.
const chosen = uspsRates.find(rate => rate.serviceName === wantedServiceName) ?? uspsRates[0];
const selectedRateId = chosen.id;
# Selection is your application's job — the response does not rank the options.
usps_rates = [rate for rate in rates if rate["carrier"] == "USPS"]
if not usps_rates:
raise RuntimeError("No USPS Rate was returned for this Shipment")
# Prefer the service you ship with; any returned USPS Rate is still valid.
chosen = next(
(rate for rate in usps_rates if rate["serviceName"] == wanted_service_name),
usps_rates[0],
)
selected_rate_id = chosen["id"]
// Selection is your application's job — the response does not rank the options.
// `rates` here is whatever list type your JSON library produced for
// data.shipmentRates[0].rates.
var uspsRates = rates.stream()
.filter(rate -> "USPS".equals(rate.carrier()))
.toList();
if (uspsRates.isEmpty()) {
throw new IllegalStateException("No USPS Rate was returned for this Shipment");
}
// Prefer the service you ship with; any returned USPS Rate is still valid.
var chosen = uspsRates.stream()
.filter(rate -> wantedServiceName.equals(rate.serviceName()))
.findFirst()
.orElse(uspsRates.get(0));
String selectedRateId = chosen.id();
Continue only when the response contains a Rate whose carrier is USPS. A
returned Rate is the only evidence the service can be used for this Shipment;
if none comes back, see Carrier Availability
rather than changing the request. serviceCode also appears on each Rate — it
is provider-dependent, so pass it through unchanged if your application stores
it, and select on serviceName instead.
Step 3 — Buy the Label
POST/api/v1/shipments/{shipmentId}/labels
The body is the Rate you chose, nested:
{ "rate": { "id": "c4a1b2d3-4e5f-6a7b-8c9d-0e1f2a3b4c5d" } }
- cURL
- JavaScript
- 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(`${base}/api/v1/shipments/${shipmentId}/labels`, {
method: 'POST',
headers: {...auth, 'Content-Type': 'application/json'},
body: JSON.stringify({rate: {id: selectedRateId}}),
});
let labelId;
if (labelResponse.status === 202) {
// Still running. Read the Order until its Label identifier appears.
const orderResponse = await fetch(`${base}/api/v1/orders/${data.order.id}`, {headers: auth});
labelId = (await orderResponse.json()).data.labelId;
} else {
// The root id is the Shipment. The Label is the nested one.
labelId = (await labelResponse.json()).data.postageLabel.id;
}
if (!labelId) {
// The purchase has not finished. Stop here rather than downloading a Label
// that does not exist yet, and read the Order again later at a cadence
// appropriate for your application.
console.log('Label purchase still running. Read the Order again later.');
process.exit(0);
}
label_response = requests.post(
f"{base}/api/v1/shipments/{shipment_id}/labels",
headers=auth,
json={"rate": {"id": selected_rate_id}},
)
if label_response.status_code == 202:
# Still running. Read the Order until its Label identifier appears.
order = requests.get(
f"{base}/api/v1/orders/{data['order']['id']}", headers=auth
).json()["data"]
label_id = order.get("labelId")
else:
# The root id is the Shipment. The Label is the nested one.
label_id = label_response.json()["data"]["postageLabel"]["id"]
if not label_id:
# The purchase has not finished. Stop here rather than downloading a Label
# that does not exist yet, and read the Order again later at a cadence
# appropriate for your application.
print("Label purchase still running. Read the Order again later.")
raise SystemExit(0)
var labelRequest = HttpRequest.newBuilder()
.uri(URI.create(base + "/api/v1/shipments/" + shipmentId + "/labels"))
.header("Authorization", auth)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(
"{\"rate\":{\"id\":\"" + selectedRateId + "\"}}"))
.build();
HttpResponse<String> labelResponse = client.send(
labelRequest, HttpResponse.BodyHandlers.ofString());
if (labelResponse.statusCode() == 202) {
// Still running. Read the Order until its Label identifier appears.
var orderRead = HttpRequest.newBuilder()
.uri(URI.create(base + "/api/v1/orders/" + orderId))
.header("Authorization", auth)
.GET()
.build();
HttpResponse<String> orderState = client.send(
orderRead, HttpResponse.BodyHandlers.ofString());
// labelId is data.labelId, absent until the purchase has finished. Stop here
// rather than downloading a Label that does not exist yet, and read the Order
// again later at a cadence appropriate for your application.
} else {
// The root data.id is the Shipment. The Label is data.postageLabel.id.
}
On a 200
{
"success": true,
"data": {
"id": "b91a2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"postageLabel": {"id": "f6c7d8e9-0a1b-4c2d-8e3f-4a5b6c7d8e9f", "...": "..."},
"...": "..."
},
"errors": null
}
data.id is the Shipment — the same value you sent in the path. The Label
identifier is the nested data.postageLabel.id.
On a 202
The purchase is still running. The body's operationId is diagnostic only: do
not send it to another Mailhub operation, and do not look for an
operation-status endpoint, because there is none. Read the Order instead:
/api/v1/orders/{orderId}
{
"success": true,
"data": {
"id": "5f2c1a10-2b3d-4e5f-8a9b-0c1d2e3f4a5b",
"labelId": "f6c7d8e9-0a1b-4c2d-8e3f-4a5b6c7d8e9f",
"status": 8,
"...": "..."
},
"errors": null
}
labelId is the same identifier a 200 returns as postageLabel.id. status
is the numeric Order status enum, and 8 is LabelCreated. The purchase sets
that status itself — there is no status update for you to send.
If the Order does not carry a Label ID yet, stop here. Read the Order again
later, at a cadence appropriate for your application; the contract defines none.
Continue to Step 4 only once data.labelId is present — a download built from
an absent identifier is a request for a Label that does not exist. Full
treatment:
Recover a Label Purchase After 202.
Step 4 — Download the PDF
GET/api/v1/labels/{labelId}/download
format=1 asks for PDF. The successful body is the file, not JSON.
- cURL
- JavaScript
- Python
- Java
curl -s -o label.pdf -w '%{http_code} %{content_type}\n' \
"${MAILHUB_API_BASE_URL}/api/v1/labels/${LABEL_ID}/download?format=1" \
-H "Authorization: Bearer ${MAILHUB_ACCESS_TOKEN}"
import {writeFile} from 'node:fs/promises';
const fileResponse = await fetch(
`${base}/api/v1/labels/${labelId}/download?format=1`,
{headers: auth},
);
if (fileResponse.status !== 200) {
// 202 means the file is still being generated.
throw new Error(`Label not ready: ${fileResponse.status}`);
}
// Your application chooses the filename.
await writeFile('label.pdf', new Uint8Array(await fileResponse.arrayBuffer()));
file_response = requests.get(
f"{base}/api/v1/labels/{label_id}/download",
params={"format": 1},
headers=auth,
)
# 202 means the file is still being generated.
if file_response.status_code != 200:
raise RuntimeError(f"Label not ready: {file_response.status_code}")
# Your application chooses the filename.
with open("label.pdf", "wb") as file:
file.write(file_response.content)
// ofByteArray keeps the body as bytes; it is never handed to a JSON reader.
var fileRequest = HttpRequest.newBuilder()
.uri(URI.create(base + "/api/v1/labels/" + labelId + "/download?format=1"))
.header("Authorization", auth)
.GET()
.build();
HttpResponse<byte[]> fileResponse = client.send(
fileRequest, HttpResponse.BodyHandlers.ofByteArray());
// 202 means the file is still being generated.
if (fileResponse.statusCode() != 200) {
throw new IllegalStateException("Label not ready: " + fileResponse.statusCode());
}
// Your application chooses the filename.
Files.write(Path.of("label.pdf"), fileResponse.body());
Expected result
label.pdf on disk — a PDF Label your application can store or hand to its own
printing workflow.
Common outcomes
| Step | Outcome | What to do |
|---|---|---|
| Order + Rates | 422, no Rate returned | The Order may already exist. Do not resend unchanged — see Recipe 2. |
| Pick a Rate | No carrier of USPS in the response | Stop. A returned Rate is the only evidence the service can be used here. |
| Buy the Label | 202 | Read the Order for labelId — see Recipe 4. |
| Buy the Label | 409 | A different purchase is already active, or the Shipment already has a Label. |
| Download | 202 | The file is pending or still being generated. Ask again later. |
Every other status follows the standard public error envelope — see Error Handling.
Next steps
- USPS with Mailhub — how USPS fits the generic workflow, and the current public boundaries.
- Requirements & Restrictions — the input rules behind Step 1.
- Buy a Label from a Selected Rate — repeat semantics and every documented purchase failure.
- Download a Label File — ZPL formats and binary handling in depth.