# 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.

| # | 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.

## LTL shipment booking

Less-than-truckload freight follows the same shape, just with `type: ltl` and
a different cancel endpoint.

| # | Step | Endpoint | What you do |
|  --- | --- | --- | --- |
| 1 | Create the LTL shipment | `POST /shipments` with `type: ltl` | Returns `data.id`. |
| 2 | Get LTL quotes | `GET /rates/{shipment_id}` | LTL carriers (Freightcom). |
| 3 | Book with the carrier | `POST /labels/{shipment_id}` with the chosen rate | Returns `data.confirmation_number`. |
| 4 | Cancel a booked LTL (optional) | `POST /ltl/{shipment_id}/cancel` | Voids the booking with the carrier. Use instead of `DELETE /shipments/{id}` for LTL. |


## Batch processing

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. |


## Top up credits, then buy a label

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.

## 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.

| # | 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`.

## 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.

| # | 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.


## 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:

```json
{
  "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_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](/errors-idempotency).