Pickups
Arrange a courier pickup of your label-created shipments from a Hong Kong address, follow it to the warehouse and cancel it, without opening the web portal. Eligibility, fees, cut-off times and cancellation rules are exactly those of the web pickup page.
| Endpoint | Scope | Purpose |
|---|---|---|
| GET /v1/pickups/eligible-shipments | pickups:read | Shipments that can be arranged for pickup right now. |
| GET /v1/pickups/addresses | pickups:read | Pickup addresses on file. |
| POST /v1/pickups/preview | pickups:write | Price and validate a pickup; nothing is arranged. |
| POST /v1/pickups | pickups:write | Arrange the pickup (needs the preview_token and an Idempotency-Key). |
| GET /v1/pickups | pickups:read | List pickups (includes those arranged in the web portal). |
| GET /v1/pickups/{pickup_id} | pickups:read | One pickup, always the latest state. |
| POST /v1/pickups/{pickup_id}/cancel | pickups:cancel | Cancel one pickup. |
Keys created in the dashboard carry every scope (the wildcard *), so they can use the pickup endpoints immediately. Re-authorisation applies only to OAuth connections: a key issued through OAuth (the MCP connector) carries only the scopes the user consented to, so an OAuth connection made before pickups existed has none of the three pickups:* scopes and gets 403 INSUFFICIENT_SCOPE until the user reconnects and approves them. The examples below are live (a live key and live responses); the Test keys section shows the sandbox. Until pickup arrangement is enabled for the deployment every endpoint answers 503 PICKUP_FEATURE_DISABLED.
Arrange a pickup: preview, then book
Arranging is always two calls. The preview prices and validates the pickup and saves nothing (no pickup, no address, no account change, no claim on the shipments). It returns a preview_token valid for 5 minutes. The booking repeats the same selection plus that token.
1. Find the shipments
curl https://imileexpress.com/api/v1/pickups/eligible-shipments?limit=20 \ -H "Authorization: Bearer mk_live_8ZqAvR2nLkPyWxV4DfHcM6tB"
Send the shipment_id of each row in shipment_ids. It is a pickup-selection id, not the shipment id of /v1/shipments: that one is the tracking_number next to it. Pickups report both: shipment_ids (selection ids) and tracking_numbers.
{ "data": [ { "shipment_id": "6710a1b2c3d4e5f607182931", "tracking_number": "IM2610190000000001", "reference_no": "PO-2026-1019-0003", "carrier": "yunexpress", "destination": "yunexpress", "recipient_country": "US", "created_at": "2026-10-19T02:58:11.402Z", "livemode": true } ], "next_cursor": null }
2. Preview
curl -X POST https://imileexpress.com/api/v1/pickups/preview \ -H "Authorization: Bearer mk_live_8ZqAvR2nLkPyWxV4DfHcM6tB" \ -H "Content-Type: application/json" \ -d '{ "shipment_ids": [ "6710a1b2c3d4e5f607182931" ], "mode": "fastest", "address": { "country": "HK", "name": "Chan Tai Man", "company": "Example Trading Ltd", "email": "ops@example.com", "phone": "+85291234567", "address_line1": "Unit 1203, 12/F, Tower 2, Kowloon Commerce Centre, 51 Kwai Cheong Road", "city": "Hong Kong", "district": "Kwai Chung" } }'
mode is fastest (the earliest possible date) or specific_date, which needs pickup_date (a real calendar date, YYYY-MM-DD, Hong Kong); fastest must not carry one. pickup_slot (morning, afternoon, evening) is optional. parcel_declarations (box count and weight per box, per destination in route_dates) is optional. Every preview is a fresh quote with a new token and booking_intent_id and needs no Idempotency-Key.
{ "preview_token": "<preview_token from POST /v1/pickups/preview>", "expires_at": "2026-10-19T03:17:40.123Z", "booking_intent_id": "3b9f0c1e-7a52-4d86-9c3e-0f4a1d2b6e75", "livemode": true, "pickup_date": "2026-10-21", "destination_mode": "per_carrier", "route_dates": { "yunexpress": "2026-10-21" }, "address": { "address_id": "6710a1b2c3d4e5f607182940", "alias": "API 7f3a91c2", "will_be_created": true, "resolved_version": "9d1f4c6a2b8e3057a1c94d2e6f70b3185a2c9e4d7f6018b3c5a2e9d4f1b70c63", "address": { "country": "HK", "name": "Chan Tai Man", "company": "Example Trading Ltd", "email": "ops@example.com", "phone": "+85291234567", "address_line1": "Unit 1203, 12/F, Tower 2, Kowloon Commerce Centre, 51 Kwai Cheong Road", "city": "Hong Kong", "district": "Kwai Chung" } }, "will_enable_multi_pickup": true, "shipments": [ { "shipment_id": "6710a1b2c3d4e5f607182931", "tracking_number": "IM2610190000000001", "reference_no": "PO-2026-1019-0003", "carrier": "yunexpress", "destination": "yunexpress", "recipient_country": "US", "created_at": "2026-10-19T02:58:11.402Z", "livemode": true } ], "fee": { "state": "provisional", "free": false, "group_estimated_hkd": 45, "free_threshold_parcels": 5, "manifest_total_parcel_count": 1, "manifest_total_estimated_hkd": 45, "quota_conflicts": [] } }
3. Book
curl -X POST https://imileexpress.com/api/v1/pickups \ -H "Authorization: Bearer mk_live_8ZqAvR2nLkPyWxV4DfHcM6tB" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "shipment_ids": [ "6710a1b2c3d4e5f607182931" ], "mode": "fastest", "address": { "country": "HK", "name": "Chan Tai Man", "company": "Example Trading Ltd", "email": "ops@example.com", "phone": "+85291234567", "address_line1": "Unit 1203, 12/F, Tower 2, Kowloon Commerce Centre, 51 Kwai Cheong Road", "city": "Hong Kong", "district": "Kwai Chung" }, "preview_token": "<preview_token from POST /v1/pickups/preview>" }'
The body is the preview body, unchanged, plus preview_token. If the shipments, mode, date, address or declarations differ from what was previewed, the token no longer matches and the booking is refused with 409 PICKUP_PREVIEW_STALE: preview again. pickup_slot is the exception: it is not part of the token and is checked again when the pickup is arranged, so a slot that is not open on the final date is rejected with PICKUP_INVALID_DATE_OR_SLOT instead of being replaced by another one.
{ "booking_intent_id": "3b9f0c1e-7a52-4d86-9c3e-0f4a1d2b6e75", "idempotent_replay": false, "manifest_id": "6710a1b2c3d4e5f607182950", "address_group_id": "6710a1b2c3d4e5f607182951", "address": { "address_id": "6710a1b2c3d4e5f607182940", "created": true }, "multi_pickup_enabled_now": true, "livemode": true, "pickups": [ { "id": "6710a1b2c3d4e5f607182960", "livemode": true, "simulated": false, "manifest_id": "6710a1b2c3d4e5f607182950", "address_group_id": "6710a1b2c3d4e5f607182951", "status": "requested", "destination": "yunexpress", "parcel_carriers": [ "yunexpress" ], "shipment_ids": [ "6710a1b2c3d4e5f607182931" ], "tracking_numbers": [ "IM2610190000000001" ], "pickup_date": "2026-10-21", "scheduled_pickup_date": "2026-10-21", "pickup_window": { "start": "13:00", "end": "17:00", "timezone": "Asia/Hong_Kong" }, "pickup_address": { "name": "Chan Tai Man", "phone": "+852 91234567", "email": "ops@example.com", "address_line": "Unit 1203, 12/F, Tower 2, Kowloon Commerce Centre, 51 Kwai Cheong Road, Kwai Chung" }, "carrier_tracking_number": null, "pickup_courier": null, "failure_reason": null, "fee": { "scope": "address_group", "state": "provisional", "estimated_hkd": 45, "charged_hkd": 0, "refunded_hkd": 0 }, "cancellation": { "allowed": true, "deadline": "2026-10-19T04:12:40.123Z", "reason_code": null }, "created_at": "2026-10-19T03:12:40.123Z", "updated_at": "2026-10-19T03:12:40.123Z" } ], "fee": { "state": "provisional", "free": false, "group_estimated_hkd": 45, "free_threshold_parcels": 5, "manifest_total_parcel_count": 1, "manifest_total_estimated_hkd": 45, "quota_conflicts": [] } }
Idempotency-Key and retries
- POST /v1/pickups requires an Idempotency-Key; the preview does not. Use a new key for every booking, and a new key again after every new preview (a new preview has a new token, so the body differs; the same key with a different body is 409 IDEMPOTENCY_KEY_REUSED).
- Retrying with the same key and the same body returns the first response unchanged for 24 hours, including idempotent_replay: false. That holds even if the preview_token has expired since, or the shipments are now held by this very booking.
- If you lost both the response and the key (or more than 24 hours passed), send the same body with the same preview_token and a new Idempotency-Key. The booking that already exists is found by its booking_intent_id and returned with idempotent_replay: true; nothing is booked twice. This also works after the 5-minute token has expired. The replay reports the current state (for example a fee that has become final). One exception for an inline address: if that address was changed on file after the first booking (its address-book entry was edited, or the default sender it links to changed), the retry is refused with 409 PICKUP_PREVIEW_STALE (1500107) instead. That does not mean the first booking failed. Keep the booking IDs (booking_intent_id and the id of each pickup) from the first response and read the existing pickups with GET /v1/pickups/{pickup_id} or GET /v1/pickups; do not arrange again as a retry.
- A preview_token already used for a different request is refused, usually with 409 PICKUP_BOOKING_INTENT_REUSED. If the difference is an inline address that no longer equals the booked one, the booking is refused with 409 PICKUP_PREVIEW_STALE first, so handle both codes.
Pickup address: inline or address_id
Send exactly one of the two. Sending both is a 400.
Inline address
{ "shipment_ids": [ "6710a1b2c3d4e5f607182931" ], "mode": "fastest", "address": { "country": "HK", "name": "Chan Tai Man", "company": "Example Trading Ltd", "email": "ops@example.com", "phone": "+85291234567", "address_line1": "Unit 1203, 12/F, Tower 2, Kowloon Commerce Centre, 51 Kwai Cheong Road", "city": "Hong Kong", "district": "Kwai Chung" } }
Hong Kong addresses only: country accepts HK, HKG, Hong Kong or 香港 and is always returned as HK; phone has 8–15 digits and a leading +852 is accepted. An address equal to one already on file is reused: the preview says address.will_be_created: false and the booking address.created: false. Equality is decided field by field (name, company, email, phone, address_line1, address_line2, city and district), each after its own normalisation: text fields ignore case and repeated spaces, the phone ignores a leading +852, and in the email only the domain is lower-cased while the local part keeps its case, so Ops@example.com and ops@example.com are different addresses. Otherwise it is saved when you book, with an automatic alias such as "API 7f3a91c2"; the first address becomes the default.
address_id
{ "shipment_ids": [ "6710a1b2c3d4e5f607182931" ], "mode": "specific_date", "pickup_date": "2026-10-21", "pickup_slot": "afternoon", "address_id": "6710a1b2c3d4e5f607182940", "address_resolved_version": "9d1f4c6a2b8e3057a1c94d2e6f70b3185a2c9e4d7f6018b3c5a2e9d4f1b70c63", "preview_token": "<preview_token from POST /v1/pickups/preview>" }
Take address_id and resolved_version from GET /v1/pickups/addresses and send them as address_id and address_resolved_version: put the response's resolved_version into the request field address_resolved_version (a field called resolved_version is rejected). If the address is edited after the preview the version no longer matches and the booking is 409 PICKUP_PREVIEW_STALE.
⚠ The first booking switches the account (irreversible)
The first pickup arranged through the API enrols the account in multiple pickup addresses. This cannot be undone, and it changes the web portal: the pickup page then uses the multi-address layout, and your API pickups and saved addresses appear in it. The preview tells you beforehand (will_enable_multi_pickup: true) and never switches anything; the booking reports multi_pickup_enabled_now. Before the switch, GET /v1/pickups/addresses with a live key returns data: [] and the single web-portal address in legacy_default_address. Test keys have their own separate sandbox addresses (listed with a test key, legacy_default_address is always null there) and never switch the account. An account that is excluded gets 403 PICKUP_UNAVAILABLE; an unfinished arrangement of the old kind from the web portal is 409 PICKUP_LEGACY_MANIFEST_CONFLICT.
Fees
- fee.state says whether the fee can still change; always trust it. For a new arrangement the fee belongs to an address group (fee.scope address_group): it is provisional until the last cut-off of the pickup day has run, and final afterwards, and until then the estimate can change when other pickups of that day are added or cancelled. Older fees follow their own rule: scope manifest (arranged in the web portal before the account switched) is final once its cut-off has locked it or it is settled, and scope request (a single old-style request) is final from the start. charged_hkd and refunded_hkd show what has actually moved on the wallet.
- The fee belongs to the whole address group of the day, not to one pickup: pickups arranged together for the same address and day share it, and each of them reports the same fee. fee.scope tells whose fee it is: address_group (the group), manifest (the whole day's manifest of an arrangement made before the account switched to multiple pickup addresses) or request (this pickup alone). For group and manifest count the fee once per address_group_id or manifest_id; never add it up across pickups.
- The preview shows the group estimate (group_estimated_hkd), whether it is free, the free threshold in parcels and the day's totals.
One booking, several pickups
A booking creates one pickup per destination (per_carrier) or one for the hub (imile_hub); the preview lists the date of each destination in route_dates. Each pickup has its own id, status, carrier_tracking_number and cancellation rule. scheduled_pickup_date is the date after calendar adjustments and can differ from pickup_date. Cancel and follow each pickup separately.
| status | Webhook event | Meaning |
|---|---|---|
| requested | pickup.scheduled | Arranged; not yet submitted to the courier (also when it is arranged again after a failure). |
| submitting | — | Being submitted. Transient; no event. |
| submitted | pickup.submitted | Submitted to the courier. |
| driver_dispatched | pickup.confirmed | A driver is assigned; the pickup is confirmed. |
| picked_up | pickup.picked_up | The driver has collected the parcels. Cancelling is no longer possible. |
| arrived_at_warehouse | pickup.arrived_at_warehouse | The parcels arrived at the warehouse. |
| failed | pickup.failed | The pickup failed (`failure_reason` says why). Its shipments stay held until the pickup is cancelled. |
| cancelled | pickup.cancelled | Cancelled. |
GET /v1/pickups lists newest first (limit 1–100, default 20, cursor), filtered by status, pickup_date and updated_after (RFC 3339 with a time zone; a pickup counts as updated when its fee state changed too, and updated_at is the later of the two). A cursor is only valid with the filters it was issued for.
Cancelling and refunds
POST /v1/pickups/{pickup_id}/cancel (scope pickups:cancel, no body, Idempotency-Key optional) cancels one pickup under the web portal's policy: within one hour of arranging it (cancellation.deadline), not once the driver has collected it, not while a carrier swap holds it. cancellation.allowed and reason_code on every pickup tell you in advance. A booking that created several pickups needs one call per pickup.
curl -X POST https://imileexpress.com/api/v1/pickups/6710a1b2c3d4e5f607182960/cancel \ -H "Authorization: Bearer mk_live_8ZqAvR2nLkPyWxV4DfHcM6tB" \ -H "Idempotency-Key: $(uuidgen)" # optional here
{ "pickup": { "id": "6710a1b2c3d4e5f607182960", "livemode": true, "simulated": false, "manifest_id": "6710a1b2c3d4e5f607182950", "address_group_id": "6710a1b2c3d4e5f607182951", "status": "cancelled", "destination": "yunexpress", "parcel_carriers": [ "yunexpress" ], "shipment_ids": [ "6710a1b2c3d4e5f607182931" ], "tracking_numbers": [ "IM2610190000000001" ], "pickup_date": "2026-10-21", "scheduled_pickup_date": "2026-10-21", "pickup_window": { "start": "13:00", "end": "17:00", "timezone": "Asia/Hong_Kong" }, "pickup_address": { "name": "Chan Tai Man", "phone": "+852 91234567", "email": "ops@example.com", "address_line": "Unit 1203, 12/F, Tower 2, Kowloon Commerce Centre, 51 Kwai Cheong Road, Kwai Chung" }, "carrier_tracking_number": null, "pickup_courier": null, "failure_reason": null, "fee": { "scope": "address_group", "state": "provisional", "estimated_hkd": 0, "charged_hkd": 0, "refunded_hkd": 0 }, "cancellation": { "allowed": false, "deadline": "2026-10-19T04:12:40.123Z", "reason_code": "already_cancelled" }, "created_at": "2026-10-19T03:12:40.123Z", "updated_at": "2026-10-19T03:20:05.881Z" }, "already_cancelled": false, "refund": { "state": "not_applicable", "hkd": null } }
- refund.state is not_applicable (nothing was charged, or the fee still covers other live pickups of the same address group), pending (charged and no live pickup of that fee is left, but the refund is not recorded yet: cancel again, without an Idempotency-Key or with a new one, to retry it), refunded (refund.hkd is the amount) or not_refundable (charged, and by policy never refunded because the driver had already collected; retrying changes nothing).
- Cancelling a pickup of an address group that still has other live pickups refunds nothing, because the fee is shared.
- Cancelling an already cancelled pickup answers 200 with already_cancelled: true (and retries a pending refund). To retry a pending refund send the cancel without an Idempotency-Key or with a new one: repeating the same key only replays the first response and does not retry the refund. 502 PICKUP_CANCEL_UPSTREAM_FAILED means the courier side could not confirm: the pickup is not marked cancelled on our side, the outcome at the courier may be unconfirmed, and repeating is safe.
- While a pickup holds a shipment, POST /v1/shipments/{shipment_id}/cancel is refused with 409 SHIPMENT_HAS_ACTIVE_PICKUP (1500116) and nothing is changed. The refusal is not cached. If the hold comes from a pickup arrangement, cancel that pickup first. If the shipment is held by a self drop-off declaration there is no pickup to cancel: contact customer service to release it. Then retry the same request, with the same Idempotency-Key if you like.
Self drop-off: take the parcels to the warehouse yourself
Instead of arranging a pickup you can declare that you will take label-created shipments to the carrier warehouse yourself. A declaration is free, needs no preview and no pickup address, and puts its shipments on hold. It uses the same scopes as pickups (pickups:read and pickups:write); there is no new scope.
| Endpoint | Scope | Purpose |
|---|---|---|
| GET /v1/pickups/self-dropoffs/config | pickups:read | Allowed dates per warehouse, warehouse addresses, destination mode and the cap per declaration. |
| POST /v1/pickups/self-dropoffs | pickups:write | Declare a self drop-off (needs an Idempotency-Key). |
| GET /v1/pickups/self-dropoffs | pickups:read | List self drop-off records (includes those declared in the web portal). |
| GET /v1/pickups/self-dropoffs/{self_dropoff_id} | pickups:read | One record, always the latest state. |
- Hong Kong origin only. Through the API only shipments sent from Hong Kong are accepted. If any shipment in the request is Chinese-origin, or its origin cannot be determined, the whole request is refused with 400 SELF_DROPOFF_ORIGIN_NOT_SUPPORTED (1500117) and nothing is created for any of the shipments. Send Hong Kong-origin shipments in their own request.
- One declaration, one record per destination warehouse. dropoff_date must be one of the dates in allowed_dates_by_warehouse_key of GET /v1/pickups/self-dropoffs/config. When the shipments go to more than one warehouse, the date must be allowed for every one of them, that is, in the intersection of those lists; otherwise the request is refused with 400 PICKUP_INVALID_DATE_OR_SLOT (1500111).
- For a record of the Globavend warehouse (warehouse_key globavend) the Globavend pre-alert email is sent as soon as the declaration is confirmed. A failure to send it does not fail the declaration; the system retries it.
- You cannot cancel, change or delete a declaration through the API. POST /v1/pickups/{pickup_id}/cancel does not apply to a self drop-off (the id answers 404) and the scope pickups:cancel has no effect on it. While a record is declared or arrived_at_warehouse its shipments cannot be cancelled either (409 SHIPMENT_HAS_ACTIVE_PICKUP, 1500116). To have a declaration released, contact customer service.
- The list, the detail endpoint and the webhook events also cover self drop-offs declared in the web portal, so you always see the whole picture.
1. Read the configuration
curl https://imileexpress.com/api/v1/pickups/self-dropoffs/config \ -H "Authorization: Bearer mk_live_8ZqAvR2nLkPyWxV4DfHcM6tB"
The configuration is the same for live and test keys. destination_mode.mode is per_carrier (one warehouse per carrier) or imile_hub; blocked means a declaration cannot be made right now and blocked_reason says why. A warehouse that is not set up is null. The declaration reads and checks all of this again, so a date that was allowed a moment ago can still be refused.
{ "allowed_dates_by_warehouse_key": { "yunexpress": [ "2026-10-29", "2026-10-30", "2026-11-02" ], "globavend": [ "2026-10-30", "2026-11-02" ], "imile": [] }, "max_shipments": 300, "warehouses": { "yunexpress": { "code": null, "name": "YunExpress Hong Kong warehouse", "mobile_prefix": "+852", "mobile": "55550100", "address": "Warehouse 3, 18 Example Road, Kwai Chung, New Territories, Hong Kong" }, "globavend": { "code": null, "name": "Globavend Hong Kong warehouse", "mobile_prefix": "+852", "mobile": "55550200", "address": "Unit 5, 9/F, 33 Sample Street, Tsuen Wan, New Territories, Hong Kong" }, "imile": null }, "destination_mode": { "mode": "per_carrier" }, "timezone": "Asia/Hong_Kong" }
2. Declare
curl -X POST https://imileexpress.com/api/v1/pickups/self-dropoffs \ -H "Authorization: Bearer mk_live_8ZqAvR2nLkPyWxV4DfHcM6tB" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "shipment_ids": [ "6710a1b2c3d4e5f607182932", "6710a1b2c3d4e5f607182933" ], "dropoff_date": "2026-10-30", "customer_tracking_numbers": { "globavend": "MY-GV-0001" }, "remark": "Dropped off by our courier in the afternoon" }'
shipment_ids holds 1 to max_shipments ids without duplicates: the shipment_id of GET /v1/pickups/eligible-shipments, not the tracking number. customer_tracking_numbers (your own number per warehouse; yunexpress, globavend, imile) and remark are optional. The two shipments below go to two different warehouses, so the response has two records; 2026-10-30 is a date both warehouses allow.
{ "idempotent": false, "records": [ { "self_dropoff_id": "6710a1b2c3d4e5f607182971", "status": "declared", "shipment_ids": [ "6710a1b2c3d4e5f607182932" ], "dropoff_date": "2026-10-30", "destination_mode": "per_carrier", "warehouse_key": "yunexpress", "warehouse": { "code": null, "name": "YunExpress Hong Kong warehouse", "mobile_prefix": "+852", "mobile": "55550100", "address": "Warehouse 3, 18 Example Road, Kwai Chung, New Territories, Hong Kong" }, "customer_tracking_number": null, "remark": "Dropped off by our courier in the afternoon", "created_at": "2026-10-28T03:12:40.123Z", "updated_at": "2026-10-28T03:12:40.123Z", "arrived_at": null, "cancelled_at": null, "livemode": true, "simulated": false }, { "self_dropoff_id": "6710a1b2c3d4e5f607182972", "status": "declared", "shipment_ids": [ "6710a1b2c3d4e5f607182933" ], "dropoff_date": "2026-10-30", "destination_mode": "per_carrier", "warehouse_key": "globavend", "warehouse": { "code": null, "name": "Globavend Hong Kong warehouse", "mobile_prefix": "+852", "mobile": "55550200", "address": "Unit 5, 9/F, 33 Sample Street, Tsuen Wan, New Territories, Hong Kong" }, "customer_tracking_number": "MY-GV-0001", "remark": "Dropped off by our courier in the afternoon", "created_at": "2026-10-28T03:12:40.123Z", "updated_at": "2026-10-28T03:12:40.123Z", "arrived_at": null, "cancelled_at": null, "livemode": true, "simulated": false } ] }
Idempotency-Key and retries
- POST /v1/pickups/self-dropoffs requires an Idempotency-Key. For 24 hours the same key with the same body returns the first response unchanged (idempotent: false included); the same key with a different body is 409 IDEMPOTENCY_KEY_REUSED.
- After 24 hours the key itself is forgotten but the declaration is not: the same Idempotency-Key with the same shipments, sent with the same API key, returns the original records with idempotent: true, in their current state (the status may have moved on), and creates nothing. This holds even if the new request carries a different dropoff_date. The same key with a different set of shipments is 409 IDEMPOTENCY_KEY_REUSED.
- A new key for shipments that are already declared is 409 PICKUP_SHIPMENTS_NOT_BOOKABLE (1500106). Use a new key for every new declaration.
Records, statuses and events
| status | Webhook event | Meaning |
|---|---|---|
| declared | self_dropoff.declared | The declaration is in force and its shipments are on hold. |
| arrived_at_warehouse | self_dropoff.arrived_at_warehouse | The warehouse has the parcels. The shipments stay on hold. |
| cancelled | self_dropoff.cancelled | Released by our operations team (never by you). The shipments are free again. |
GET /v1/pickups/self-dropoffs lists newest first (limit 1–100, default 20, cursor), filtered by status, dropoff_date and updated_after (RFC 3339 with a time zone). A cursor is only valid with the filters it was issued for. Another account's record, a record of the other mode and an unknown id all answer the same 404.
{ "self_dropoff_id": "6710a1b2c3d4e5f607182971", "status": "arrived_at_warehouse", "shipment_ids": [ "6710a1b2c3d4e5f607182932" ], "dropoff_date": "2026-10-30", "destination_mode": "per_carrier", "warehouse_key": "yunexpress", "warehouse": { "code": null, "name": "YunExpress Hong Kong warehouse", "mobile_prefix": "+852", "mobile": "55550100", "address": "Warehouse 3, 18 Example Road, Kwai Chung, New Territories, Hong Kong" }, "customer_tracking_number": null, "remark": "Dropped off by our courier in the afternoon", "created_at": "2026-10-28T03:12:40.123Z", "updated_at": "2026-10-30T06:41:12.000Z", "arrived_at": "2026-10-30T06:41:12.000Z", "cancelled_at": null, "livemode": true, "simulated": false }
- Three events, one per committed status change: self_dropoff.declared, self_dropoff.arrived_at_warehouse and self_dropoff.cancelled. They are delivered at-least-once like the pickup.* events (deduplicate on event_id, order by occurred_at). A subscription to "*" receives them automatically once the self drop-off API is switched on, including for declarations made in the web portal; list explicit event names if you only want some events.
- Only transitions after pickup events were switched on are published (the watermark). Earlier history, including web-portal declarations made before then, is not sent; read GET /v1/pickups/self-dropoffs for it.
- status, previous_status, occurred_at, source and simulated describe the transition; dropoff_date, destination_mode, warehouse_key and shipment_ids are a snapshot from when the event was first emitted and never change for that event_id. Try a receiver with GET /v1/webhooks/{id}/sample?event_type=self_dropoff.declared.
{ "event_id": "5b1c7d2e-93a4-4f0b-8c6d-2e7a41f9b3d0", "event_type": "self_dropoff.arrived_at_warehouse", "livemode": true, "created_at": "2026-10-30T06:42:20.510Z", "data": { "self_dropoff_id": "6710a1b2c3d4e5f607182971", "status": "arrived_at_warehouse", "previous_status": "declared", "occurred_at": "2026-10-30T06:41:12.000Z", "source": "system", "dropoff_date": "2026-10-30", "destination_mode": "per_carrier", "warehouse_key": "yunexpress", "shipment_ids": [ "6710a1b2c3d4e5f607182932" ], "simulated": false } }
Test keys: the self drop-off simulator
With an mk_test_ key nothing is sent to any carrier and no email is sent. Only test shipments (created with a test key) can be declared, and the records (livemode: false, simulated: true) are kept apart from live data in both directions. About 60 seconds after the declaration (within about a minute of that) a record moves from declared to arrived_at_warehouse. Send simulation: {"outcome": "cancelled"} (test keys only; a live key gets 400 PICKUP_INVALID_REQUEST) to make it move to cancelled instead, as if our operations team had cancelled it. Reading a record never advances it. While a test record is declared or arrived_at_warehouse it holds its test shipments; cancelled releases them.
curl -X POST https://imileexpress.com/api/v1/pickups/self-dropoffs \ -H "Authorization: Bearer mk_test_rh9ABekJfP31cN7sQ8jKvLwY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "shipment_ids": [ "6710a1b2c3d4e5f607182932", "6710a1b2c3d4e5f607182933" ], "dropoff_date": "2026-10-30", "customer_tracking_numbers": { "globavend": "MY-GV-0001" }, "remark": "Dropped off by our courier in the afternoon", "simulation": { "outcome": "cancelled" } }'
{ "idempotent": false, "records": [ { "self_dropoff_id": "6710a1b2c3d4e5f607182971", "status": "declared", "shipment_ids": [ "6710a1b2c3d4e5f607182932" ], "dropoff_date": "2026-10-30", "destination_mode": "per_carrier", "warehouse_key": "yunexpress", "warehouse": { "code": null, "name": "YunExpress Hong Kong warehouse", "mobile_prefix": "+852", "mobile": "55550100", "address": "Warehouse 3, 18 Example Road, Kwai Chung, New Territories, Hong Kong" }, "customer_tracking_number": null, "remark": "Dropped off by our courier in the afternoon", "created_at": "2026-10-28T03:12:40.123Z", "updated_at": "2026-10-28T03:12:40.123Z", "arrived_at": null, "cancelled_at": null, "livemode": false, "simulated": true }, { "self_dropoff_id": "6710a1b2c3d4e5f607182972", "status": "declared", "shipment_ids": [ "6710a1b2c3d4e5f607182933" ], "dropoff_date": "2026-10-30", "destination_mode": "per_carrier", "warehouse_key": "globavend", "warehouse": { "code": null, "name": "Globavend Hong Kong warehouse", "mobile_prefix": "+852", "mobile": "55550200", "address": "Unit 5, 9/F, 33 Sample Street, Tsuen Wan, New Territories, Hong Kong" }, "customer_tracking_number": "MY-GV-0001", "remark": "Dropped off by our courier in the afternoon", "created_at": "2026-10-28T03:12:40.123Z", "updated_at": "2026-10-28T03:12:40.123Z", "arrived_at": null, "cancelled_at": null, "livemode": false, "simulated": true } ] }
Switches: until the self drop-off API is switched on for your environment, the config and declaration endpoints answer 503 PICKUP_FEATURE_DISABLED (1500103) with data.reason_code public_api_self_dropoff_disabled; with a live key a declaration also needs the web self drop-off feature (reason_code self_dropoff_disabled). The list and detail endpoints only need the pickup API master switch (reason_code public_api_pickups_disabled). A test key depends on the two API switches only.
Test keys: the pickup simulator
With an mk_test_ key the same endpoints run on a simulator. No courier is contacted and no money moves (charged_hkd and refunded_hkd stay 0), and the account is not switched to multiple pickup addresses. A test key sees only test shipments (created with a test key), test pickups and test addresses; ids of the other mode answer 404 in both directions.
- Timetable after arranging: submitted after about 30 seconds, driver_dispatched after 1 minute, picked_up after 3 minutes, arrived_at_warehouse after 4 minutes, each within about a minute of those times. Reading a pickup never advances it.
- Send simulation: {"outcome": "failure"} (test keys only; a live key gets 400) to make the submission fail so the pickup ends as failed. A failed test pickup keeps holding its shipments until it is cancelled, so cancel it within one hour of arranging it: later it can no longer be cancelled and its test shipment stays held; create a new test shipment instead.
- Cancelling follows the live rules (within one hour, not once picked up). Fees are always provisional and are recomputed on every read from the day's current pickups.
- A test key depends only on the pickup API master switch: the switches that stop live pickups do not stop the simulator. While the master switch is off (503 PICKUP_FEATURE_DISABLED) already-arranged test pickups pause, and they catch up from the time they were arranged once it is on again.
{ "shipment_ids": [ "6710a1b2c3d4e5f607182931" ], "mode": "fastest", "address": { "country": "HK", "name": "Chan Tai Man", "company": "Example Trading Ltd", "email": "ops@example.com", "phone": "+85291234567", "address_line1": "Unit 1203, 12/F, Tower 2, Kowloon Commerce Centre, 51 Kwai Cheong Road", "city": "Hong Kong", "district": "Kwai Chung" }, "simulation": { "outcome": "failure" } }
The same calls with a test key
The request is the same; only the key and the answers differ. The preview returns livemode: false and will_enable_multi_pickup: false. The booking returns livemode: false, manifest_id: null and multi_pickup_enabled_now: false, and every pickup has simulated: true, livemode: false and no manifest_id.
curl -X POST https://imileexpress.com/api/v1/pickups/preview \ -H "Authorization: Bearer mk_test_rh9ABekJfP31cN7sQ8jKvLwY" \ -H "Content-Type: application/json" \ -d '{ "shipment_ids": [ "6710a1b2c3d4e5f607182931" ], "mode": "fastest", "address": { "country": "HK", "name": "Chan Tai Man", "company": "Example Trading Ltd", "email": "ops@example.com", "phone": "+85291234567", "address_line1": "Unit 1203, 12/F, Tower 2, Kowloon Commerce Centre, 51 Kwai Cheong Road", "city": "Hong Kong", "district": "Kwai Chung" } }'
{ "preview_token": "<preview_token from POST /v1/pickups/preview>", "expires_at": "2026-10-19T03:17:40.123Z", "booking_intent_id": "3b9f0c1e-7a52-4d86-9c3e-0f4a1d2b6e75", "livemode": false, "pickup_date": "2026-10-21", "destination_mode": "per_carrier", "route_dates": { "yunexpress": "2026-10-21" }, "address": { "address_id": "6710a1b2c3d4e5f607182940", "alias": "API 7f3a91c2", "will_be_created": true, "resolved_version": "9d1f4c6a2b8e3057a1c94d2e6f70b3185a2c9e4d7f6018b3c5a2e9d4f1b70c63", "address": { "country": "HK", "name": "Chan Tai Man", "company": "Example Trading Ltd", "email": "ops@example.com", "phone": "+85291234567", "address_line1": "Unit 1203, 12/F, Tower 2, Kowloon Commerce Centre, 51 Kwai Cheong Road", "city": "Hong Kong", "district": "Kwai Chung" } }, "will_enable_multi_pickup": false, "shipments": [ { "shipment_id": "6710a1b2c3d4e5f607182931", "tracking_number": "IM2610190000000001", "reference_no": "PO-2026-1019-0003", "carrier": "yunexpress", "destination": "yunexpress", "recipient_country": "US", "created_at": "2026-10-19T02:58:11.402Z", "livemode": false } ], "fee": { "state": "provisional", "free": false, "group_estimated_hkd": 45, "free_threshold_parcels": 5, "manifest_total_parcel_count": 1, "manifest_total_estimated_hkd": 45, "quota_conflicts": [] } }
curl -X POST https://imileexpress.com/api/v1/pickups \ -H "Authorization: Bearer mk_test_rh9ABekJfP31cN7sQ8jKvLwY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "shipment_ids": [ "6710a1b2c3d4e5f607182931" ], "mode": "fastest", "address": { "country": "HK", "name": "Chan Tai Man", "company": "Example Trading Ltd", "email": "ops@example.com", "phone": "+85291234567", "address_line1": "Unit 1203, 12/F, Tower 2, Kowloon Commerce Centre, 51 Kwai Cheong Road", "city": "Hong Kong", "district": "Kwai Chung" }, "preview_token": "<preview_token from POST /v1/pickups/preview>" }'
{ "booking_intent_id": "3b9f0c1e-7a52-4d86-9c3e-0f4a1d2b6e75", "idempotent_replay": false, "manifest_id": null, "address_group_id": "6710a1b2c3d4e5f607182951", "address": { "address_id": "6710a1b2c3d4e5f607182940", "created": true }, "multi_pickup_enabled_now": false, "livemode": false, "pickups": [ { "id": "6710a1b2c3d4e5f607182960", "livemode": false, "simulated": true, "manifest_id": null, "address_group_id": "6710a1b2c3d4e5f607182951", "status": "requested", "destination": "yunexpress", "parcel_carriers": [ "yunexpress" ], "shipment_ids": [ "6710a1b2c3d4e5f607182931" ], "tracking_numbers": [ "IM2610190000000001" ], "pickup_date": "2026-10-21", "scheduled_pickup_date": "2026-10-21", "pickup_window": { "start": "13:00", "end": "17:00", "timezone": "Asia/Hong_Kong" }, "pickup_address": { "name": "Chan Tai Man", "phone": "+852 91234567", "email": "ops@example.com", "address_line": "Unit 1203, 12/F, Tower 2, Kowloon Commerce Centre, 51 Kwai Cheong Road, Kwai Chung" }, "carrier_tracking_number": null, "pickup_courier": null, "failure_reason": null, "fee": { "scope": "address_group", "state": "provisional", "estimated_hkd": 45, "charged_hkd": 0, "refunded_hkd": 0 }, "cancellation": { "allowed": true, "deadline": "2026-10-19T04:12:40.123Z", "reason_code": null }, "created_at": "2026-10-19T03:12:40.123Z", "updated_at": "2026-10-19T03:12:40.123Z" } ], "fee": { "state": "provisional", "free": false, "group_estimated_hkd": 45, "free_threshold_parcels": 5, "manifest_total_parcel_count": 1, "manifest_total_estimated_hkd": 45, "quota_conflicts": [] } }
Webhooks: pickup.* events
Subscribe with POST /v1/webhooks and list the seven names in events (or use "*", which now also delivers pickup.* events). One event is produced for every committed status change: pickup.scheduled, pickup.submitted, pickup.confirmed, pickup.picked_up, pickup.arrived_at_warehouse, pickup.failed, pickup.cancelled (mapping in the table above). Test pickups deliver only to livemode: false endpoints, with simulated: true. Signing, retries and the headers are those of every other event; see the Webhooks guide.
- At-least-once: a delivery can repeat with the same event_id, so deduplicate on it. The same transition always has the same event_id and the same data payload; the envelope's created_at is the time of that delivery and changes on a redelivery, so do not compare whole request bodies. Order events by occurred_at.
- Events are produced about once a minute and then delivered, so expect a delay of about one to two minutes after the status change. Do not treat them as instantaneous. A retry of an earlier delivery can arrive after a later event: compare occurred_at, and read GET /v1/pickups/{pickup_id} for the latest state.
- A status the courier skipped produces no event; a pickup can go from submitted straight to picked_up. status and previous_status are the raw values (previous_status can be submitting, which has no event of its own).
- Two kinds of fields. status, previous_status, occurred_at, source, failure_reason (only on pickup.failed), pickup_id and simulated describe the transition itself. pickup_date, scheduled_pickup_date, shipment_ids, destination, carrier_tracking_number and manifest_id are a snapshot of the pickup when the event was first emitted (normally within about a minute of the transition); they never change for that event_id, redeliveries included. For the latest state call GET /v1/pickups/{pickup_id}.
- Try your receiver without waiting for a real pickup: GET /v1/webhooks/{id}/sample?event_type=pickup.confirmed returns a genuinely signed sample (nothing is delivered). POST /v1/webhooks/{id}/test always fires shipment.delivered, so use the sample route for pickup events.
- Only transitions after events were switched on are published; earlier history is not sent.
{ "event_id": "8eced096-51f4-4e1d-9b45-71fc6a89c020", "event_type": "pickup.confirmed", "livemode": true, "created_at": "2026-10-21T05:41:12.340Z", "data": { "pickup_id": "6710a1b2c3d4e5f607182960", "manifest_id": "6710a1b2c3d4e5f607182950", "status": "driver_dispatched", "previous_status": "submitted", "occurred_at": "2026-10-21T05:40:31.000Z", "source": "poll", "pickup_date": "2026-10-21", "scheduled_pickup_date": "2026-10-21", "shipment_ids": [ "6710a1b2c3d4e5f607182931" ], "destination": "yunexpress", "carrier_tracking_number": "FU2610210001234", "failure_reason": null, "simulated": false } }
Errors
Errors use the common envelope; most pickup errors add data.reason_code with the specific cause. The codes below are also in the sys_code table of the Error codes guide.
| sys_code | HTTP | Name and when |
|---|---|---|
| 1500101 | 400 | PICKUP_INVALID_REQUEST — Invalid body, cursor or filter; `simulation` sent with a live key; an `address_id` request for an account without a saved pickup address. |
| 1500102 | 404 | PICKUP_NOT_FOUND — Unknown pickup or address, another account's, or one of the other mode (test/live). Also a selected shipment (in `shipment_ids`) that does not exist, belongs to another account or is of the other mode. |
| 1500103 | 503 | PICKUP_FEATURE_DISABLED — Pickup arrangement through the API is not enabled right now (see `data.reason_code`). Safe to retry later. |
| 1500104 | 503 | PICKUP_HALTED — Pickup arrangement is paused temporarily. Reads keep working. |
| 1500105 | 403 | PICKUP_UNAVAILABLE — This account may not arrange pickups through the API. |
| 1500106 | 409 | PICKUP_SHIPMENTS_NOT_BOOKABLE — A selected shipment is no longer eligible (already held by a pickup, cancelled, ...). Re-read eligible-shipments. |
| 1500107 | 409 | PICKUP_PREVIEW_STALE — The preview no longer matches (expired, the selection or address changed, another pickup changed the day). Preview again. |
| 1500108 | 409 | PICKUP_BOOKING_INTENT_REUSED — This preview_token was already used for a different request. |
| 1500109 | 409 | PICKUP_LEGACY_MANIFEST_CONFLICT — An earlier web-portal arrangement of the old kind must finish first. |
| 1500110 | 409 | PICKUP_STATE_CONFLICT — The state changed while the request ran (or the day's pickups are locked). Read the pickup and decide again. |
| 1500111 | 400 | PICKUP_INVALID_DATE_OR_SLOT — The date or `pickup_slot` is not open (checked again at booking). |
| 1500112 | 409 | PICKUP_CANCEL_WINDOW_EXPIRED — More than one hour has passed since the pickup was arranged. |
| 1500113 | 409 | PICKUP_NOT_CANCELLABLE — Not cancellable: the driver has collected it, it is in progress, a carrier swap holds it, or (data.reason_code env_mismatch) it was placed on a different carrier environment than this server uses. Only operations can resolve env_mismatch, so contact customer service. A 409 cached under the same Idempotency-Key is not invalidated automatically: retry without a key or with a new one once operations confirms the fix. |
| 1500114 | 502 | PICKUP_CANCEL_UPSTREAM_FAILED — The cancellation did not complete. data.reason_code external_side_effects_disabled: carrier calls are switched off on our side, no carrier call was made and nothing changed. fuuffy_cancel_failed: the courier side could not confirm it, so the carrier outcome may be unconfirmed (the pickup is not marked cancelled on our side). Repeating the request is safe. This is not the 503 PICKUP_FEATURE_DISABLED gate, which refuses the request before any cancellation is attempted. |
| 1500115 | 503 | PICKUP_CONFIGURATION_UNAVAILABLE — Pickup settings for this period are not ready; contact support. |
| 1500116 | 409 | SHIPMENT_HAS_ACTIVE_PICKUP — `POST /v1/shipments/{id}/cancel` while a pickup holds the shipment. If a pickup arrangement holds it, cancel that pickup first; if a self drop-off declaration holds it there is no pickup to cancel, so contact customer service to release it; then retry. |
| 1500117 | 400 | SELF_DROPOFF_ORIGIN_NOT_SUPPORTED — `POST /v1/pickups/self-dropoffs` with a shipment that is not Hong Kong-origin. data.reason_code origin_not_supported: a Chinese-origin shipment; origin_undetermined: the origin of a shipment cannot be determined. The whole request is refused and nothing is created. |