Skip to content
Last updated

Common Workflows

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.


Courier shipment lifecycle

Create, rate, label, and ship a single courier package. The most common V5 flow — covers ~80% of carrier integrations.

#StepEndpointWhat you do
1Create the shipmentPOST /shipments with type: courierSend to_address + packages. Returns data.id (the V5 shipment_id).
2List ratesGET /rates/{shipment_id}Pick a carrier.service_code (e.g. canada_post.expedited).
3Buy the labelPOST /labels/{shipment_id} with the chosen serviceCharges credits. Returns data.label_url (PDF) + data.tracking_code.
4Schedule 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.


LTL shipment booking

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.

#StepEndpointWhat you do
1Get LTL quotesPOST /ltl/quoteSend addresses, pallets, accessorials, pickup_date and the dock hours in pickup (ready_at / close_at, local HH:MM). Pick a service.
2Book with the carrierPOST /ltl/shipments with the same body plus serviceCharges 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).
3Follow the shipmentGET /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.
4Cancel 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.


Batch processing

Group many shipments into a single batch, generate labels for all of them, and drain per-shipment failures.

#StepEndpointWhat you do
1Create the batchPOST /batchesSend { name, shipment_ids }. Returns data.id.
2Trigger processingPOST /batches/{batch_id}/processReturns 202. Label generation runs async.
3Poll statusGET /batches/{batch_id}Repeat until data.status != "processing". Realistic clients back off (1s → 2s → 4s).
4Drain errorsGET /batches/{batch_id}/errorsPer-shipment failures with the validation problem.
5Print the closeout labelGET /batches/{batch_id}/closeoutOnce 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.

Close a batch of labels bought one at a time

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.

#StepEndpointWhat you do
1Collect the labelled shipmentsPOST /batches / POST /batches/{batch_id}/shipmentsAdd shipments that already have labels.
2Close the batchPOST /batches/{batch_id}/closeReturns the closed batch with data.closeout_id. 422 lists any shipment still without a label (details.unlabelled_shipment_ids). Safe to retry.
3Print the closeout labelGET /batches/{batch_id}/closeoutSigned URL (30 minutes) to the PDF with the closeout barcode and the parcel count per service.

Ship an imported store order

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.

#StepEndpointWhat you do
1Find the orderGET /orders?status=awaiting_shipmentTake data[].id. Filter with store_id or search (the store's order id).
2Create the shipment from itPOST /shipmentsSend { "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 itPOST /batches / POST /batches/{batch_id}/shipmentsUse the shipment id from step 2.
4Buy the labelPOST /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 (409 on POST /labels), naming the existing shipment.
  • PUT /shipments/{shipment_id} keeps the link; only order_id cannot 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.

Top up credits, then buy a label

Labels are paid from the account's credit balance. If the balance is low, top up first.

#StepEndpointWhat you do
1Charge the payment methodPOST /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).
2Confirm the new balanceGET /credits/balanceSanity-check that the top-up landed and covers the upcoming label cost.
3Buy the labelPOST /labels/{shipment_id} with the chosen serviceDebits the credit balance.

Always send an Idempotency-Key header on step 1. Top-up retries without a key double-charge.


Classify and approve a product

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.

#StepEndpointWhat you do
1Suggest an HS codePOST /products/classify with { title, description }Returns data.hs_code.
2Verify the manufacturer (optional)POST /products/verify-manufacturer with { manufacturer, country_of_origin }CUSMA / country-of-origin compliance check.
3Create the productPOST /products with the verified hs_code and source fieldsPersists 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.


Hold a US order until its products are CUSMA approved

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.

#StepEndpointWhat you do
1Read the productGET /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.
2Ship, 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:

  • expiring is 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 reports approved right up to the expiry date, so expiring only appears where that flag is not set.
  • in_group means 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_duty can.
{
  "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.status is not ddp_status. ddp_status is the customs classification; a product has to be DDP Approved with a US / CA / MX manufacturer before CUSMA applies at all, and one that is not reports cusma.status: "ineligible".
  • A product in a product group inherits the group's certificate and reports approved_in_group / in_group / expired_in_group, with group_sku naming the parent.
  • active_for_duty is 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 status Approved and a US / CA / MX manufacturer country on the DDP record), and an approved certificate covers today. When both hold it covers approved, approved_in_group, expiring, an in_group product 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 status can still report active_for_duty: false: status also 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.
  • expiring means the certificate ends within 30 days. It is not a reason to hold on its own — the certificate still covers the product — but ship on active_for_duty as everywhere else, since an expiring product can still report false through the DDP-country case above.
  • GET /products returns the same cusma block for every product on the page.

Compare retail and enterprise tier rates (opt-in)

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.

#StepWhat you do
1Enable the account settingAdmin enables Show Retail & Enterprise Rates via API (show_tier_rates_api). Uses standard published margins — ignores the account's custom pricing rules.
2Quote ratesGET /rates/{shipment_id} or POST /rates as usual.
3Read tier_pricingWhen 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.

Tell a carrier timeout apart from lane non-coverage

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. ..."
      }
    ]
  }
}
#StepWhat you do
1Quote ratesAny 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.
2Read meta.excluded_servicesEach entry names an omitted service and a machine-readable reason (see the ExcludedService schema for the full enum).
3Branch on reasoncarrier_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_timeout omissions, 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_services instead 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_seconds always tells you what was applied).

Other endpoint-level flows

These don't need a multi-step description — they're single calls or small glue logic.

  • Quote rates without creating a shipment — POST /rates with shipment details. Returns rates. No shipment row is created.
  • Validate addresses — POST /addresses/validate before POST /shipments to 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.

Idempotency & retry rules

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.