API reference

API v2 — items

The v2 surface cleans up the box vocabulary: every box of an order is an item, addressed only by your own references. Everything else — authentication, scopes, errors, the polling model — is unchanged from v1.

Migrating an existing v1 integration?

The migration guide (PDF) walks through it in order: the endpoint map, which reference means what, the status strings that changed, and the three endpoints that exist only here. This page stays the authoritative reference for field-level detail.

v1 and v2 are separate, parallel contracts. v1 keeps serving exactly as documented on the other pages of this portal; v2 is where new vocabulary lands. Pick one version per integration and migrate deliberately — never mix versions inside one workflow.

What changes on v2, completely:

  • The box array on create is items[] (v1: parcels[]), and your per-box reference is item_id (v1: item_id).
  • An item has no Starmile tracking number of its own. v1's items[].parcel_id is gone — you address a item by your item_id or its merchant_tracking, and Starmile's only tracking reference is the order's tracking_number.
  • The create response returns the Starmile reference as tracking_number and echoes your own order_id back (v1 overloaded order_id with the Starmile number).
  • Status-pool rows name your order reference order_id (v1: external_parent_id) and the item reference item_id (v1: external_id).
  • The status vocabulary is not the same.v1 is frozen on the strings it was handshaked with; v2 carries Starmile's current catalogue, where several of those statuses have been renamed or split by leg. See the table below before you migrate.

Endpoints that only read — GET /api/v2/services, GET /api/v2/rates — and POST /api/v2/partner/events behave exactly as their v1 twins; only the path is versioned.

GET /api/v2/orders/label is the one exception. It returns the same PDF as v1, but it addresses the order the v2 way: tracking_number is ours, order_id is yours, and merchant_tracking names one box. parcel_id does not exist on v2 at all, because v2 mints no per-box Starmile tracking for it to address. On v1, order_id means Starmile's number — that meaning is gone here, so an order_id holding one of our tracking numbers answers 404.

Status vocabulary: v1 vs v2

Since v1 was handshaked, Starmile's internal status catalogue was renamed, and several statuses were split so the journey names which hub or which customs leg a package reached. v1 is deliberately frozen: those statuses are projected back to the string v1 always sent, so a v1 integration never changes under you.

v2 does no such projection — it carries the current name. So a switch written against the left column stops matching the day you move to v2. Where a v1 string split into several, v2 tells you which one actually happened; map every value on the right to whatever the left-hand string meant in your system.

v1 sendsv2 sends instead
waiting_for_arrivalawaiting_arrival
accepted_at_hubdws_processed
received_at_hubreceived_at_origin_hubreceived_at_central_hubreceived_at_destination_hub
sortedsorted_at_central_hubsorted_at_destination_hubsorted_at_regional_hubsorted_at_transfer_hub
consolidation_rackedconsolidation_stuffed
parcel_racked_at_hubparcel_stuffed_at_hub
dispatched_from_hubdispatched_from_origin_hubdispatched_from_central_hubdispatched_from_destination_hubdispatched_from_regional_hub
loading_in_progressreceived_by_tms
in_transit_to_hubin_transit_to_destination_hub
arrived_at_terminalarrived_at_import_terminalarrived_at_export_terminal
customs_in_progressimport_customs_clearance_in_progressexport_customs_clearance_in_progress
customs_completedimport_customs_clearance_completedexport_customs_clearance_completed
customs_releasedhanded_over_to_tms
handover_to_carrierhanded_over_to_carrier
out_for_deliverydelivery_attempt
forwarded_to_destinationpudo_forward
accepted_at_pudoparcel_stuffed_at_pudo
cancelledorder_cancelled

Paperwork statuses are the exception — they do not change

customs_hold, customs_hold_resolved, declaration_pushed and declaration_confirmed describe paperwork rather than a movement, and they cross the wire as the same strings on both versions. In particular a customs hold is always customs_hold — never a direction-specific spelling — and the direction, when you need it, is carried by the surrounding terminal and clearance statuses. Which hold it is comes from reason, not from the status.

Migrating the status pool: the cursor space is new

The v2 pool's cursorcomes from a different id space than v1's. A cursor you stored while polling v1 is meaningless on v2 — never carry it over. When you switch, drain v2 from since=0 (optionally per order via external_parent_id) and de-duplicate against what you already processed on v1, then continue with v2 cursors only.

Create an order (v2)

POST/api/v2/orders

Identical behavior to v1 — same required fields, same service_id resolution, idempotent on your order_id, same region handling — with the body's parcels[] spelled items[] and item_id spelled item_id. All other fields (recipient, delivery, products, gov_id, shipping_cost, …) are unchanged from Create an order.

A replayed order_id answers 200 with duplicate: true and the same body shape.

Body — what differs from v1

itemsarray, requiredoptional
One entry per physical box (v1: parcels[]). Same per-box fields: merchant_tracking, package_type, weight_grams, length_mm, width_mm, height_mm, products[].
items[].item_idstring, optionaloptional
Your own per-box reference (same key as v1). Must be unique across your orders; it is how you address the item afterwards.
curl -X POST 'https://api.starmile.io/api/v2/orders' \
  -H 'Authorization: Bearer <access_token>' \
  -H 'Content-Type: application/json' \
  -d '{
    "service_id": 12,
    "order_id": "PO-10294",
    "customer_name": "Jane Doe",
    "customer_phone": "+994500000000",
    "items": [
      {
        "item_id": "BOX-1",
        "merchant_tracking": "MT-0001",
        "weight_grams": 1200,
        "products": [
          { "name": "Running shoes", "declared_value": 50, "currency": "USD", "quantity": 1 }
        ]
      }
    ]
  }'

Add an item

POST/api/v2/orders/{order_id}/items

Add a new item to an order that already has more than one item. The body is a single item in the same shape as one entry of the create items[]; the item carries no Starmile tracking of its own, so the 201 returns the order's tracking_number, your order_id, and the new item by its own references. A reused item_id, or a merchant_tracking held by a real item, is 422; an unidentified package already at the hub under that barcode is matched instead. Scope: orders:update.

An order created with a single item carries that box on the order itself and has no separate items to add to — adding one is refused 409. Create a new order for the additional package instead.

Body

item_idstringoptional
Optional. Your own reference for the item. Must be unique across your orders.
merchant_trackingstringoptional
Optional. The physical sticker code.
package_typestringoptional
Optional. One of: fragile, breakable, liquid.
weight_gramsintegeroptional
Optional. Declared gross weight (records-only).
length_mm / width_mm / height_mmintegeroptional
Optional. Declared dimensions in mm.
productsarrayoptional
Required. The item's contents (same shape as create).
curl -X POST 'https://api.starmile.io/api/v2/orders/PO-10294/items' \
  -H 'Authorization: Bearer <access_token>' \
  -H 'Content-Type: application/json' \
  -d '{
    "item_id": "BOX-3",
    "merchant_tracking": "CN773300012345",
    "weight_grams": 1200,
    "products": [
      { "name": "Ceramic mug", "hs_code": "691200", "declared_value": 12.5, "currency": "USD", "quantity": 1 }
    ]
  }'

Update or cancel an item

PATCH/api/v2/orders/{order_id}/items/{item_id}

Both path segments are your references — the order_id and item_idyou sent on create. Same rules as v1's parcel update: only while the order is at its flow's first step, products replaces the full list, and a late merchant_tracking that matches a package already waiting at the hub is adopted, not rejected.

POST …/items/{item_id}/cancel cancels one item (and the order, when it was the last active one); POST /api/v2/orders/{order_id}/cancel cancels the whole order. Scopes: orders:update / orders:cancel.

curl -X PATCH 'https://api.starmile.io/api/v2/orders/PO-10294/items/BOX-1' \
  -H 'Authorization: Bearer <access_token>' \
  -H 'Content-Type: application/json' \
  -d '{ "weight_grams": 1500 }'

Split items onto a new order

POST/api/v2/orders/{order_id}/split

The v2 twin of v1's parcel split: peel one or more items out of a multi-item order and move them onto one fresh clone of the order (same service, flow, customer and destination). The body names the items with item_ids — an array of your own references (one or more, no duplicates) — and the new order with new_order_id, your own reference, so you can track and manage it like any other order. The 201follows the v2 wire: the new order's Starmile reference is tracking_number, order_id echoes your new_order_id, and each moved item carries no tracking of its own under items (item_id + merchant_tracking). Scope: orders:update.

Move a single item and the new order is a single-item order, stored the way v2 stores any single-item order — the item lives on the order itself, with no separate item to address (fetch its label and track it by its order_id, and you cannot add an item to it). Move two or more and the new order is a genuine multi-item order that keeps its items.

An ordinary order is split only while the items are at their flow's first step (else 409); a consolidation order can be split any time before its boxes are packed (an item already received or shelved can still be pulled out), and only a packed consolidation is refused 409. A folded single-item source order has no item to split off, and naming every item would leave the source empty — both 409; an item_id that names no active item is 404; a new_order_id already used, or equal to the source order_id, is 422.

Body

item_idsstring[]optional
Required. Your own item_id references for the items to move onto the new order — one or more, distinct. All move onto the one new order; the source keeps the rest.
new_order_idstringoptional
Required. Your own reference for the new order the items are moved onto. Must be unused across your orders and different from the source order_id.
reasonstringoptional
Optional free-text reason, kept on the new order's history.
curl -X POST 'https://api.starmile.io/api/v2/orders/PO-10294/split' \
  -H 'Authorization: Bearer <access_token>' \
  -H 'Content-Type: application/json' \
  -d '{
    "item_ids": ["BOX-2", "BOX-3"],
    "new_order_id": "PO-10294-B",
    "reason": "ships separately"
  }'

Poll the status pool (v2)

GET/api/v2/partner/changes

Same polling model as Status updates: page with since, persist next_cursor, poll again immediately while has_more is true. Same optional filters (tracking_number, external_parent_id), same reason / reason_detail codes, and the feed still opens with the status the order was created in.

Row differences from v1: order_id is your own order reference, and item_id (set only on item-scoped rows, e.g. a single box reaching received_at_hub) replaces external_id.

# Poll from your last v2 cursor (NOT a v1 cursor — see the migration note)
curl 'https://api.starmile.io/api/v2/partner/changes?since=48210' \
  -H 'Authorization: Bearer <access_token>'

# One order's history, by YOUR own reference
curl 'https://api.starmile.io/api/v2/partner/changes?since=0&order_id=PO-10294' \
  -H 'Authorization: Bearer <access_token>'

Order detail

GET/api/v2/orders/detail

One of your orders in full. The status pool says what happened to an order; this says what the order is: its status, where it is going, the carton's measured dimensions and weights, and the items inside with the products you declared. Requires the orders:read scope — a sender default, so a credential that creates orders already holds it. v2-only.

Order-level. After consolidation the order isthe physical box: the items you announced are packed into one carton that carries the order's tracking_number, and every measurement, scan and handover from then on happens to that carton. So status and dimensions belong to the order, and items[] are its contents — each named by your own item_id and merchant_tracking, with no status and no dimensions of its own.

Read measured_at before you trust the numbers

dimensions carries the same fields whether the carton has crossed the hub scale or not. measured_at is when it last did; null means nothing has been measured yet, and every number you see is still your own declaration.

Query

tracking_numberstringoptional
Starmile's order reference, returned as tracking_number on create. Required unless order_id is given.
order_idstringoptional
Your own order reference — the order_id you sent on create. Required unless tracking_number is given.

Response — order

tracking_numberstringoptional
Starmile's order reference.
order_idstringoptional
Your own order reference.
statusstringoptional
The order's current status — the same vocabulary as the v2 status pool.
created_atstringoptional
ISO 8601. cancelled_at and cancel_reason are null while the order is live.
origin_country, destination_countrystringoptional
ISO 3166-1 alpha-2.
consolidationobjectoptional
required (bool); consolidated_at and package_sealed_at, ISO 8601 or null until the carton is packed and sealed.
deliveryobjectoptional
type (home · pudo · locker), address, address_first, address_second, zip, notes, region and parent_region exactly as you sent them, and pudo { id, name } for a PUDO order (null otherwise).
declared_value, currencystringoptional
The order's declared value as you sent it; a decimal string.
dimensionsobjectoptional
See below.
itemsarrayoptional
The contents — see below. A withdrawn (cancelled) item is not listed.

Response — dimensions

weight_gramsintegeroptional
Actual (scale) weight of the carton, in grams.
volumetric_weight_gramsintegeroptional
Dimensional weight, computed where the carton was measured. Null until then.
chargeable_weight_gramsintegeroptional
The greater of the two — the weight declared to customs.
length_mm, width_mm, height_mm, volume_cm3integeroptional
The carton's measurements. Null until measured.
measured_atstringoptional
ISO 8601 — when the hub scale last measured the carton. Null means every number above is still what you declared.

Response — items[]

item_idstringoptional
Your per-item reference, as sent on create.
merchant_trackingstringoptional
Your box barcode, as sent on create.
package_typestringoptional
fragile · breakable · liquid, or null.
productsarrayoptional
What you declared: name, hs_code, quantity, unit_price (decimal string), currency, weight_grams, description.

The recipient's name, phone, email and government ID are deliberately not returned. An order that is not yours answers 404, never 403 — the two are deliberately indistinguishable.

curl -G 'https://api.starmile.io/api/v2/orders/detail' \
  -H 'Authorization: Bearer <access_token>' \
  --data-urlencode 'tracking_number=CMX0000012345'

Delivery code

GET/api/v2/orders/delivery-code

The code the recipient reads to the courier at the door; a wrong code refuses the handover. Starmile's own tracking page shows it to the recipient, but your customers may never visit that page — so fetch it here and show it in your own app.

Order-level. One order carries one code however many items it ships in, so there is no item parameter, and the code does not change after a failed attempt.

Available as soon as the order exists. The code is minted when the order is created, not when the recipient is messaged — there is nothing to poll for. Requires the delivery_code:read scope — not pod:read. The two are separate grants: this returns a live secret that still authorises a handover, while a proof of delivery is a record of one that already happened, and a credential may hold either without the other.

Read status before you display anything

A missing code is an ordinary answer, not an error — the response is still 200. not_required and not_yet_issued mean different things and you should act differently on each.

Query

tracking_numberstringoptional
Starmile's order reference, returned as tracking_number on create. Required unless order_id is given.
order_idstringoptional
Your own order reference — the order_id you sent on create. Required unless tracking_number is given.

status

activestringoptional
The code is in force — show it to your customer.
usedstringoptional
The parcel is delivered, so the code has done its job. Still returned, for a completed-order screen.
not_requiredstringoptional
This organization does not use delivery codes at all. delivery_code is null — show nothing.
not_yet_issuedstringoptional
No code on the order. Only orders created before delivery codes existed; you will not see this on new orders.

An order that is not yours answers 404, never 403 — the two are deliberately indistinguishable, so the endpoint cannot be used to discover which references exist.

curl -G 'https://api.starmile.io/api/v2/orders/delivery-code' \
  -H 'Authorization: Bearer <access_token>' \
  --data-urlencode 'order_id=PO-10294'

Proof of delivery

GET/api/v2/orders/pod

The signed record of a handover, returned as application/pdf. Requires the pod:read scope — not delivery_code:read; a partner may hold either without the other.

One section per handover, and the two delivery paths are shaped differently. A courier records one stop per box, so each box gets its own section. A pickup at a PUDO point is one collection for the whole order — a single act, verified once — so it is one section listing every box it covered. A multi-box order may show either shape.

Each section carries your own references, the recipient, the address, when it happened in the delivery country's own timezone, and then only the evidence that was actually captured: the delivery code in full, the signature, the photo.

Nothing is printed empty

The document describes what was recorded, never what the proof-of-delivery policy requires today. A proof that was never captured does not appear at all — not as a blank row — and a handover with none says so in one sentence.

Query

tracking_numberstringoptional
Starmile's order reference. Required unless order_id is given.
order_idstringoptional
Your own order reference. Required unless tracking_number is given.
merchant_trackingstringoptional
Optional. Narrows the document to one box. Omit it and every delivered box of the order is included.

409 and 404 are not interchangeable

409 means the order — or the box you named — is not delivered yet. It exists and it is yours, so there is no reference problem to go looking for. 404 means the order is not yours or we do not hold it; the two are deliberately the same answer.

A partlydelivered order still returns its document. The handovers that happened are real evidence, and the boxes still outstanding are listed under “Not yet delivered”.

curl -G 'https://api.starmile.io/api/v2/orders/pod' \
  -H 'Authorization: Bearer <access_token>' \
  -H 'Accept: application/pdf' \
  --data-urlencode 'order_id=PO-10294' \
  -o pod.pdf