End-to-end flows that chain V5 endpoints together. Each workflow lists the exact endpoint sequence and what to do with the response between steps.
Create, rate, label, and ship a single courier package. The most common V5 flow — covers ~80% of carrier integrations.
| # | Step | Endpoint | What you do |
|---|---|---|---|
| 1 | Create the shipment | POST /shipments with type: courier | Send to_address + packages. Returns data.id (the V5 shipment_id). |
| 2 | List rates | GET /rates/{shipment_id} | Pick a carrier.service_code (e.g. canada_post.expedited). |
| 3 | Buy the label | POST /labels/{shipment_id} with the chosen service | Charges credits. Returns data.label_url (PDF) + data.tracking_code. |
| 4 | Schedule a pickup (optional) | POST /pickups with { shipment_ids: [...] } | Only valid for courier shipments (FedEx/UPS/Purolator via Freightcom). |
Use an Idempotency-Key header on steps 1, 3, and 4 so network retries can't double-charge or double-book.
Less-than-truckload freight has its own endpoints, all under the ltl:read / ltl:write scopes. Quoting is stateless: the booking call repeats the quote body and adds the chosen service.
| # | Step | Endpoint | What you do |
|---|---|---|---|
| 1 | Get LTL quotes | POST /ltl/quote | Send addresses, pallets, accessorials, pickup_date and the dock hours in pickup (ready_at / close_at, local HH:MM). Pick a service. |
| 2 | Book with the carrier | POST /ltl/shipments with the same body plus service | Charges credits. Returns data.id, bol_number, pickup_confirmation_number, a signed bol_url, and pro_number if the carrier assigned one already. Add pickup.instructions for the driver (dock number, gate code). |
| 3 | Follow the shipment | GET /ltl/shipments/{shipment_id} | status moves booked → in_transit → delivered (or exception). pro_number appears once the carrier assigns it; charges.adjustments lists reweighs, reclasses and accessorials invoiced after pickup. Fetch again for a fresh bol_url: each link expires after 30 minutes. |
| 4 | Cancel a booking (optional) | DELETE /ltl/shipments/{shipment_id} | Voids the booking with the carrier while it is still booked. |
Carrier adjustments also appear on GET /billing/transactions (needs billing:read): match them to the shipment with shipment_id and read the cause from adjustment.reason.
Use an Idempotency-Key header on step 2 so a network retry can't book twice.
Group many shipments into a single batch, generate labels for all of them, and drain per-shipment failures.
| # | Step | Endpoint | What you do |
|---|---|---|---|
| 1 | Create the batch | POST /batches | Send { name, shipment_ids }. Returns data.id. |
| 2 | Trigger processing | POST /batches/{batch_id}/process | Returns 202. Label generation runs async. |
| 3 | Poll status | GET /batches/{batch_id} | Repeat until data.status != "processing". Realistic clients back off (1s → 2s → 4s). |
| 4 | Drain errors | GET /batches/{batch_id}/errors | Per-shipment failures with the validation problem. |
| 5 | Print the closeout label | GET /batches/{batch_id}/closeout | Once every shipment is purchased the batch closes by itself and data.closeout_id is set. Returns a signed URL to the closeout PDF to put with the parcels. |
If you buy each label as you pack (POST /labels/{shipment_id}), there is nothing left for process to buy — it answers 422 and leaves the batch open. Close the batch instead; nothing is purchased or charged.
| # | Step | Endpoint | What you do |
|---|---|---|---|
| 1 | Collect the labelled shipments | POST /batches / POST /batches/{batch_id}/shipments | Add shipments that already have labels. |
| 2 | Close the batch | POST /batches/{batch_id}/close | Returns the closed batch with data.closeout_id. 422 lists any shipment still without a label (details.unlabelled_shipment_ids). Safe to retry. |
| 3 | Print the closeout label | GET /batches/{batch_id}/closeout | Signed URL (30 minutes) to the PDF with the closeout barcode and the parcel count per service. |
Orders imported from a connected store (Shopify, Amazon, …) are listed by GET /orders. To have the label mark the order fulfilled in the store and send it the tracking number, create the shipment from the order with store_order_id — the order's id, not the store's order number. order_id alone is only a reference and links nothing.
| # | Step | Endpoint | What you do |
|---|---|---|---|
| 1 | Find the order | GET /orders?status=awaiting_shipment | Take data[].id. Filter with store_id or search (the store's order id). |
| 2 | Create the shipment from it | POST /shipments | Send { "shipments": [{ "store_order_id": …, "packages": [...] }] }, plus region, service, … on the row as usual. The recipient and lines default to the order's; a to_address or items you send replaces the order's as a whole. The created shipment is in data.shipments[] and carries store_id — that is the link. A refused row is in data.errors[]. |
| 3 | (optional) Batch it | POST /batches / POST /batches/{batch_id}/shipments | Use the shipment id from step 2. |
| 4 | Buy the label | POST /labels/{shipment_id} | The store is sent the fulfillment and tracking number, per the store's fulfillment settings (immediately, delayed, or when the parcel is scanned). |
Steps 2 and 4 can be one call: POST /labels takes store_order_id too. If that call answers 422 or 402 with details.shipment_id, the carrier already holds a booking for the shipment: retry with POST /labels/{shipment_id} rather than creating the shipment again.
- An order has one shipment. A second create for the same order is refused with
conflict(409onPOST /labels), naming the existing shipment. PUT /shipments/{shipment_id}keeps the link; onlyorder_idcannot be changed on a linked shipment.- Drafts made by a scheduled import (
POST /crons/{cron_id}/run) are linked the same way and additionally have your automation rules applied. They are created on your account's region. - Voiding the label cancels the fulfillment in the store where the store supports it.
Labels are paid from the account's credit balance. If the balance is low, top up first.
| # | Step | Endpoint | What you do |
|---|---|---|---|
| 1 | Charge the payment method | POST /credits/top-up with { amount, payment_method_id? } | payment_method_id is optional — defaults to the account's default card. Returns data.id (the new transaction). |
| 2 | Confirm the new balance | GET /credits/balance | Sanity-check that the top-up landed and covers the upcoming label cost. |
| 3 | Buy the label | POST /labels/{shipment_id} with the chosen service | Debits the credit balance. |
Always send an Idempotency-Key header on step 1. Top-up retries without a key double-charge.
Customs declarations need a per-product HS code. V5 wraps the TRU classification API so you can ask for a suggested code, optionally verify the manufacturer, then persist the product.
| # | Step | Endpoint | What you do |
|---|---|---|---|
| 1 | Suggest an HS code | POST /products/classify with { title, description } | Returns data.hs_code. |
| 2 | Verify the manufacturer (optional) | POST /products/verify-manufacturer with { manufacturer, country_of_origin } | CUSMA / country-of-origin compliance check. |
| 3 | Create the product | POST /products with the verified hs_code and source fields | Persists the row that store imports and later shipment-creation calls reference. |
If an existing product enters Manufacturer Failed after import, you can run just step 2 on it and re-call POST /products/{sku}/approve-classification.
CUSMA certificates are submitted and reviewed on the dashboard's Products page, but their state is on the product record, so you can gate shipping on it without opening the dashboard.
| # | Step | Endpoint | What you do |
|---|---|---|---|
| 1 | Read the product | GET /products/{sku} | data.cusma.active_for_duty says whether a certificate covers the product today. data.cusma.status is the same label the Products page shows. |
| 2 | Ship, or hold | — | Gate on active_for_duty: true means a certificate covers the product now, so ship. Hold while it is false. |
Gate on active_for_duty, not on status. status is the dashboard's label, and two of its values do not split the way a shipping gate needs:
expiringis covered — the certificate ends within 30 days but still applies today. It is a cue to renew, not a reason to hold. It is also not a reliable renewal reminder: approval sets the product's internal "apply CUSMA" flag, and a product carrying it reportsapprovedright up to the expiry date, soexpiringonly appears where that flag is not set.in_groupmeans either that the group has no live certificate or that the group's certificate is expiring. The Products page collapses the two because a child product cannot renew its group's certificate, so the status alone cannot tell them apart.active_for_dutycan.
{
"data": {
"sku": "MAPLE-SYRUP-250",
"ddp_status": "Approved",
"cusma": {
"status": "approved",
"active_for_duty": true,
"submission_id": 4821,
"reference_code": "CUSMA-2026-004821",
"blanket_period_from": "2026-01-01",
"blanket_period_to": "2026-12-31",
"group_sku": null
}
}
}Notes:
cusma.statusis notddp_status.ddp_statusis the customs classification; a product has to be DDPApprovedwith a US / CA / MX manufacturer before CUSMA applies at all, and one that is not reportscusma.status: "ineligible".- A product in a product group inherits the group's certificate and reports
approved_in_group/in_group/expired_in_group, withgroup_skunaming the parent. active_for_dutyis what the duty calculation actually uses, and it is the field to gate on. It is true when both hold: the product's DDP record is itself CUSMA-eligible (DDP statusApprovedand a US / CA / MX manufacturer country on the DDP record), and an approved certificate covers today. When both hold it coversapproved,approved_in_group,expiring, anin_groupproduct whose group certificate is merely expiring, and a pending renewal where an earlier approved version still covers today.- That first condition is why an approved-looking
statuscan still reportactive_for_duty: false:statusalso accepts the manufacturer country from the product row. If the two copies disagree, no CUSMA treatment is applied — correct the manufacturer country on the DDP record. expiringmeans the certificate ends within 30 days. It is not a reason to hold on its own — the certificate still covers the product — but ship onactive_for_dutyas everywhere else, since anexpiringproduct can still reportfalsethrough the DDP-country case above.GET /productsreturns the samecusmablock for every product on the page.
Some reseller and partner integrations need to show what a shipment would cost at Stallion's standard retail (small business) and enterprise published margins, alongside the account's own quoted rate.
| # | Step | What you do |
|---|---|---|
| 1 | Enable the account setting | Admin enables Show Retail & Enterprise Rates via API (show_tier_rates_api). Uses standard published margins — ignores the account's custom pricing rules. |
| 2 | Quote rates | GET /rates/{shipment_id} or POST /rates as usual. |
| 3 | Read tier_pricing | When enabled, each rate object may include a tier_pricing object with retail and enterprise sub-objects. |
Each tier entry has the same fields as the primary rate (service, subtotal, tax, total, currency, add_ons, etc.), so clients can display them without a separate parser. When the setting is off or the rate cannot be re-margined (no captured postage cost), the tier_pricing key is omitted.
The same opt-in field is available on v4 get-rates responses (rates[].tier_pricing).
Constraints
- Tier totals are derived from the already-fetched carrier cost — no extra carrier API calls.
- Stallion Protection add-ons are recomputed per tier because insurance pricing depends on the rate total.
- Booking always uses the account's primary quoted rate, not a tier comparison.
A service can be missing from a rates response for two very different reasons: the carrier didn't answer in time (transient — retry and it may come back), or the carrier answered and genuinely doesn't serve the lane. Every rates response (GET /rates/{shipment_id}, POST /rates, POST /rates/estimate) carries a meta object that makes the difference explicit:
{
"data": [ ...rates... ],
"meta": {
"timeout_seconds": 7,
"excluded_services": [
{
"postage_type_id": 141,
"service": "intelcom.standard",
"carrier": "Intelcom",
"service_name": "Intelcom Standard",
"reason": "carrier_timeout",
"message": "The carrier did not respond within the 7-second rate timeout. ..."
}
]
}
}| # | Step | What you do |
|---|---|---|
| 1 | Quote rates | Any rates endpoint. Optionally pass timeout (1–25 seconds; body field on the POST endpoints, query param on the GET) to override the default rate-fetch timeout. |
| 2 | Read meta.excluded_services | Each entry names an omitted service and a machine-readable reason (see the ExcludedService schema for the full enum). |
| 3 | Branch on reason | carrier_timeout / carrier_error → transient; retry (optionally with a higher timeout) before concluding anything about the lane. no_rate_for_lane → the carrier answered and offered nothing; safe to treat as non-coverage for this shipment. |
Choosing a timeout
- A higher value (e.g. 15–25) trades latency for completeness — fewer
carrier_timeoutomissions, useful for batch quoting or building a lane-coverage dataset. - A lower value (e.g. 3–5) favors a fast checkout: slow carriers are dropped and reported in
meta.excluded_servicesinstead of holding up the response. - The cap is 25 seconds so the HTTP request itself can still complete; omitting the parameter uses the platform default (
meta.timeout_secondsalways tells you what was applied).
These don't need a multi-step description — they're single calls or small glue logic.
- Quote rates without creating a shipment —
POST /rateswith shipment details. Returns rates. No shipment row is created. - Validate addresses —
POST /addresses/validatebeforePOST /shipmentsto surface bad zip/postal codes early. - Manage imported orders —
POST /orders,GET /orders,PUT /orders/{order_id}. Delete only before linking to a non-voided shipment. - Find drop-off locations —
GET /locations. Render the returned branch and partner-site addresses; refresh periodically because the catalog changes.
All mutating endpoints (POST, PUT, DELETE) accept an Idempotency-Key header. A retried request with the same key inside 24h returns the original response byte-for-byte. Reusing a key with a different body yields 409 idempotency_conflict. See Errors & Idempotency.