Shipments
The shipment object, its lifecycle, and how to create, retrieve, and cancel shipments.
The shipment is the API’s central entity: it describes a parcel traveling from an origin to a destination. It starts in DRAFT status, becomes a label (guía) when you buy a rate, and moves through its lifecycle until delivery.
The lifecycle
DRAFT → LABEL_PURCHASED → READY_FOR_PICKUP → PICKED_UP → IN_TRANSIT → OUT_FOR_DELIVERY → DELIVERED
| Status | Meaning |
|---|---|
DRAFT |
Just created; has rates, editable and cancelable at no cost |
PENDING |
Submitted, awaiting label purchase |
LABEL_PURCHASED |
Label bought, awaiting pickup |
READY_FOR_PICKUP |
Carrier notified for pickup |
PICKED_UP |
Carrier picked up the package |
IN_TRANSIT |
On the way |
OUT_FOR_DELIVERY |
Last-mile delivery |
DELIVERED |
Delivered |
RETURNED |
Returned to sender |
FAILED |
Delivery failed |
CANCELLED |
Cancelled |
Two ways to buy a label
POST /v1/shipments serves two flows. Pick the one that fits each shipment:
1. Two-step (compare and choose). Create the shipment; the response carries rates[] with quotes from the available carriers. You compare price and speed, pick one, and buy with its rateId at POST /v1/shipments/:id/label. Best when cost or speed decides on each shipment. If you know the carrier, pass carrierCode and serviceLevel at create to filter rates to a category.
2. One-call. If you already know the carrier and service, or just want the cheapest, add a purchase object at create. The label comes back in the same response, with no second step. Use it for automation and predictable volume. See One-call buy.
| Two-step | One-call | |
|---|---|---|
| When to use it | Price or speed decides on each shipment | You know the service, or want the cheapest rate |
| Requests | Create → buy with rateId |
One: POST /v1/shipments with purchase |
| What it returns | rates[] to compare |
The label (label + purchasedRate) ready |
| Ideal for | Stores optimizing for cost | Automation and predictable volume |
Endpoints
| Method | Path | Scope | Description |
|---|---|---|---|
POST |
/v1/shipments |
shipments:write |
Create a shipment (returns rates[] inline; with purchase, buys the label in the same call) |
GET |
/v1/shipments |
shipments:read |
List shipments (paginated and filterable) |
GET |
/v1/shipments/stats |
shipments:read |
Shipment counts by status |
GET |
/v1/shipments/:id |
shipments:read |
Detail with snapshots, label, and events |
PUT |
/v1/shipments/:id |
shipments:write |
Update a DRAFT shipment |
DELETE |
/v1/shipments/:id |
shipments:write |
Cancel a shipment |
POST |
/v1/shipments/:id/return |
shipments:write |
Create a return shipment (route reversed) |
Create a shipment
For each address role (from, to, return) send exactly one of two variants: a saved address ID (fromAddressId) or an inline object (fromAddress). Inline addresses can be persisted to your address book with saveToAddressBook: true.
Body parameters
parcelobject
The package: length, width, height (cm) and weight (kg). Accepts dimensionUnit (cm | in), weightUnit (kg | g | lb | oz), and description.
objectfromAddressId?string
Saved origin. Send exactly one of fromAddressId or fromAddress.
stringfromAddress?object
Inline origin (see the address object). Can be persisted with saveToAddressBook: true.
objecttoAddressId?string
Saved destination. Send exactly one of toAddressId or toAddress.
stringtoAddress?object
Inline destination (see the address object).
objectreturnAddressId?string
Saved return address (at most one; defaults to the origin).
stringreturnAddress?object
Inline return address.
objectpurchase?object
Buy the label in the same call: carrierCode + serviceCode (exact service) or strategy: cheapest. See One-call buy.
objectexternalId?string
Your own reference, such as an order number or folio. Query it later with ?externalId=.
stringcarrierCode?string
Optional: scope the quotes to this carrier from creation (does not buy on its own).
stringserviceLevel?string
Optional: scope the service (e.g. standard, express).
stringrequestInsurance?boolean
Request insurance on the declared value. Buying an insured rate creates a real policy (see Insurance & claims).
booleanfalsedeclaredValue?number
Declared value in MXN (insurance coverage).
numbermetadata?object
Your own key-value pairs; returned as-is.
objectcurl -X POST https://api.sendit.mx/v1/shipments \
-H "X-API-Key: sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"externalId": "ORD-2026-001",
"fromAddressId": "clx_origin_address",
"toAddress": {
"contactName": "María López",
"contactPhone": "+5213312345678",
"street": "Av. López Mateos",
"exteriorNumber": "45",
"neighborhood": "Jardines del Sol",
"city": "Zapopan",
"state": "JAL",
"postalCode": "45050",
"country": "MX",
"saveToAddressBook": false
},
"parcel": {
"length": 30, "width": 20, "height": 15, "weight": 2.5,
"description": "Electronics"
},
"requestInsurance": true,
"declaredValue": 5000,
"metadata": { "orderId": "shopify-12345" }
}'
The 201 response returns the shipment in DRAFT with its address snapshots, the event history, and inline rates (rates[], see Rates):
{
"success": true,
"data": {
"id": "clxq1w2e3r4t5y6u7i8o9p0a",
"status": "DRAFT",
"externalId": "ORD-2026-001",
"fromAddressSnapshot": {
"contactName": "Bruno Sánchez",
"city": "Ciudad de México",
"postalCode": "03100",
"savedAddressId": "clx_origin_address"
},
"toAddressSnapshot": {
"contactName": "María López",
"city": "Zapopan",
"postalCode": "45050",
"savedAddressId": null
},
"parcel": { "length": 30, "width": 20, "height": 15, "weight": 2.5 },
"rates": [
{
"id": "DHL_standard_a1b2c3",
"carrierCode": "DHL",
"serviceCode": "EXPRESS_WORLDWIDE",
"serviceLevel": "standard",
"totalPrice": 326.82,
"currency": "MXN"
}
],
"events": [
{ "status": "DRAFT", "description": "Shipment created", "occurredAt": "2026-07-17T12:00:00.000Z" }
],
"createdAt": "2026-07-17T12:00:00.000Z"
}
}
One-call buy
Add a purchase object to POST /v1/shipments to create the shipment and buy the label in one request. Pick one way to select the service: carrierCode + serviceCode for the exact product, or strategy: "cheapest" for the lowest-priced rate. Never send both, and never omit both. With a scoped API key you need labels:write in addition to shipments:write.
purchase parameters
carrierCode?string
Exact carrier; used together with serviceCode. Mutually exclusive with strategy.
stringserviceCode?string
Carrier-native service code; used together with carrierCode.
stringstrategy?string
cheapest: auto-selects the lowest-priced rate. Mutually exclusive with carrierCode + serviceCode.
stringlabelFormat?string
Format of the generated label: PDF | ZPL.
stringPDFasync?boolean
true returns labelPurchaseAttempt inside the 201 response. Retrieve its statusUrl.
booleanfalsecurl -X POST https://api.sendit.mx/v1/shipments \
-H "X-API-Key: sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"fromAddressId": "clx_origin_address",
"toAddress": {
"contactName": "María López",
"contactPhone": "+5213312345678",
"street": "Av. López Mateos",
"exteriorNumber": "45",
"neighborhood": "Jardines del Sol",
"city": "Zapopan",
"state": "JAL",
"postalCode": "45050",
"country": "MX"
},
"parcel": { "length": 30, "width": 20, "height": 15, "weight": 2.5 },
"purchase": { "strategy": "cheapest", "labelFormat": "PDF" }
}'
On success, the 201 response additionally includes label (with trackingNumber, labelUrl, charged) and purchasedRate:
{
"success": true,
"data": {
"id": "clxq1w2e3r4t5y6u7i8o9p0a",
"status": "LABEL_PURCHASED",
"label": {
"trackingNumber": "1234567890",
"labelUrl": "https://labels.sendit.mx/clxq1w2e3r4t5y6u7i8o9p0a/1234567890.pdf",
"charged": "326.82",
"currency": "MXN"
},
"purchasedRate": {
"id": "DHL_standard_a1b2c3",
"carrierCode": "DHL",
"serviceCode": "EXPRESS_WORLDWIDE",
"serviceLevel": "standard",
"totalPrice": 326.82
}
}
}
With purchase.async: true, the outer response remains 201. You receive labelPurchaseAttempt instead of label. Retrieve its statusUrl or use label.purchase.completed. See Asynchronous operations.
The shipment is always created first: if the purchase fails, you’re left with a DRAFT you can finish through the two-step flow.
| Code | When | How to resolve it |
|---|---|---|
402 INSUFFICIENT_BALANCE |
The wallet can’t cover the label | The shipment stays DRAFT; fund the wallet and buy with POST /v1/shipments/:id/label |
422 ONE_CALL_BUY_RATES_PENDING |
Carriers were slow (>8s) to quote | Poll ratesPollUrl, then buy with the rateId |
422 ONE_CALL_BUY_NO_MATCHING_RATE |
No rate matched your selection | Pick one from details.availableRates |
400 INVALID_INPUT |
purchase had both selection modes, or neither |
Send carrierCode + serviceCode or strategy, not both |
Send several boxes together
A shipment carries one parcel. To dispatch several boxes in the same operation, create one shipment per box and group them into a batch. You buy every label in one call and generate one manifest (manifiesto) for the carrier.
Addresses freeze into snapshots
At creation, every address is copied into an immutable snapshot (fromAddressSnapshot, toAddressSnapshot, returnAddressSnapshot). Editing or deleting the saved address later never alters historical shipments.
Always read addresses from the snapshot, not the ID:
// Correct: the snapshot is the canonical value
const origin = shipment.fromAddressSnapshot;
// Wrong: the saved address may have changed or been deleted
const origin = await getAddress(shipment.fromAddressId);
Inside each snapshot, savedAddressId records which address-book entry it came from (null for one-time addresses).
List and filter
GET /v1/shipments?status=IN_TRANSIT&carrierCode=DHL&limit=50
| Filter | Match |
|---|---|
status |
Exact (DRAFT, IN_TRANSIT, …) |
statuses |
Several statuses at once (repeat the param or comma-separate) |
carrierCode |
Exact (DHL, FEDEX, ESTAFETA, …) |
trackingNumber |
Partial (contains) |
externalId |
Exact |
search |
Free-text over tracking number and externalId |
createdFrom / createdTo |
Creation date range (ISO; from inclusive, to exclusive) |
Cursor pagination and the advanced operators are covered in Pagination & filtering. For dashboards, GET /v1/shipments/stats returns per-status counts in one call.
Retrieve the detail
GET /v1/shipments/:id returns the full shipment: snapshots, parcel, label summary (if any), and the event history:
{
"success": true,
"data": {
"id": "clxq1w2e3r4t5y6u7i8o9p0a",
"status": "IN_TRANSIT",
"trackingNumber": "TEST-DHL-A1B2C3D4",
"publicTrackingToken": "2f68c611-30af-4a86-9cde-793413af5f65",
"trackingUrl": "https://app.sendit.mx/track/2f68c611-30af-4a86-9cde-793413af5f65",
"events": [
{ "status": "IN_TRANSIT", "description": "Package in transit", "occurredAt": "2026-07-17T09:12:00.000Z" },
{ "status": "PICKED_UP", "description": "Package picked up", "occurredAt": "2026-07-17T08:03:00.000Z" }
]
}
}
Update a draft
Only DRAFT shipments can be edited; after that they lock:
curl -X PUT https://api.sendit.mx/v1/shipments/{id} \
-H "X-API-Key: sk_test_..." \
-H "Content-Type: application/json" \
-d '{ "externalId": "ORD-2026-001-v2" }'
A shipment that already moved on returns 409 SHIPMENT_ALREADY_PROCESSED.
Cancel a shipment
curl -X DELETE https://api.sendit.mx/v1/shipments/{id} \
-H "X-API-Key: sk_test_..."
You cannot cancel in DELIVERED, RETURNED, FAILED, or if it is already CANCELLED. If the shipment already has a purchased label and you want the money back, void the label first. See Refunds & voids.
Create a return shipment
POST /v1/shipments/:id/return creates a new DRAFT shipment with the route reversed, linked to the original via returnForShipmentId:
- from = the original destination (where the package is now)
- to = the original return address, falling back to the origin
- parcel = copied from the original (override it if the return is re-boxed)
The response is identical to POST /v1/shipments, with inline rates[]. You buy the return label through the normal flow. Rules:
- The original must have a label (
DRAFT,PENDING,CANCELLED, andRETURNEDoriginals are rejected). - One active return per shipment. Cancelling the return frees the slot.
- No returns of returns.
- Works end-to-end in test mode.