DEVELOPERS / MERCHANT API V1
The forwarding workflow, as an API.
Idempotent order pre-alerts, packed quotes, normalized tracking and HMAC-signed webhooks — the same system that runs the warehouse. Keys are issued by application; there is no self-service signup yet.
Overview
Base URL: https://porter.woolii.com. All endpoints live under /api/v1/merchant, accept and return JSON, and are bounded to the authenticated merchant account — a resource id alone is never sufficient to read data. Every response carries an X-Request-Id header; include it when you contact us about a request. Requests are rate-limited per key; a 429 includes a Retry-After header. Webhook endpoints must be public https URLs — loopback, private and cloud-metadata addresses are rejected.
The API automates the existing parcel-forwarding workflow: you (or your supplier) buy and own the goods, parcels arrive at the assigned China receiving workflow, and WooliiPorter coordinates receiving, optional inspection, consolidation, the packed quote, dispatch and provider tracking. It is not an inventory-led 3PL API: there are no SKU stock, reservation or pick-list resources.
Authentication
Authenticate with a Bearer key. Keys are scoped to one account and one environment. Awp_sk_test_… (sandbox) key works as soon as your account exists — before approval — so you can integrate against the sandbox while your application is reviewed. A wp_sk_live_…(production) key becomes available once your account is approved (active) — you create it yourself in the developer console, the same way as a sandbox key. Each key carries explicit scopes: orders:read, orders:write, tracking:read, webhooks:write.
curl https://porter.woolii.com/api/v1/merchant/orders \
-H "Authorization: Bearer wp_sk_test_..."Keys can be rotated and revoked without losing the request audit trail. Store them like passwords.
Sandbox
Orders created with a wp_sk_test_… key are sandbox orders: they carry"sandbox": true, and they never enter real fulfilment — no warehouse receiving, no provider tracking, no dispatch. They share your account's data space, so use a distinctexternalOrderId range (e.g. a test- prefix) to keep them recognizable.
Because there is no warehouse behind a sandbox order, advance its lifecycle yourself to exercise your webhook receiver end to end:
# after POST /orders with a sandbox key
curl -X POST https://porter.woolii.com/api/v1/merchant/orders/ord_.../simulate \
-H "Authorization: Bearer wp_sk_test_..." \
-H "Content-Type: application/json" \
-d '{ "event": "quote.ready" }'
# events: parcel.received | quote.ready | shipment.dispatched | shipment.deliveredSimulation is rejected (409 merchant_not_sandbox) for production orders — those advance from real warehouse events only.
Idempotency
Every mutating request requires an Idempotency-Key header (any unique string up to 200 characters). Retrying with the same key and the same body returns the original stored response — a network timeout can never create a duplicate order. The same key with a different body returns 409 merchant_idempotency_conflict.
Errors
All errors use one envelope:
{
"error": {
"code": "merchant_order_conflict",
"message": "Human-readable summary",
"requestId": "req_…",
"details": {}
}
}merchant_auth_missing / merchant_auth_invalidMissing or unknown API key.
merchant_key_revoked / merchant_key_expiredThe key was revoked or has passed its expiry.
merchant_scope_missingThe key lacks the scope this endpoint requires.
merchant_account_inactiveThe merchant account is not active.
merchant_order_not_foundNo such resource in your account. Cross-account ids also return 404.
merchant_idempotency_conflictThe Idempotency-Key was already used with a different request body.
merchant_external_order_existsexternalOrderId already maps to an order (id included in details).
merchant_parcel_conflictA tracking number already belongs to an active order.
merchant_order_conflictThe order is past the stage this action allows.
merchant_validation_failedField validation failed; details.issues lists each problem.
merchant_webhook_url_rejectedThe webhook URL is not https or resolves to a private/loopback/metadata address.
merchant_rate_limitedPer-key request budget exceeded. Honor the Retry-After header.
merchant_sandbox_capDaily sandbox order limit reached for this account.
Create an order pre-alert
POST/api/v1/merchant/orders
Registers a forwarding pre-alert: the destination, shipment preferences and the domestic supplier parcels you expect to arrive. Returns the Woolii order, its receiving instructions and the recorded parcels. externalOrderId is unique per account.
curl https://porter.woolii.com/api/v1/merchant/orders \
-H "Authorization: Bearer wp_sk_test_..." \
-H "Idempotency-Key: order-SO-1042-v1" \
-H "Content-Type: application/json" \
-d '{
"externalOrderId": "SO-1042",
"destination": {
"recipientName": "Ada Merchant",
"phone": "+1 415 000 0000",
"country": "US",
"province": "CA",
"city": "San Francisco",
"street": "1 Mission St",
"postalCode": "94105"
},
"parcels": [
{ "trackingNumber": "SF1234567890",
"productName": "Ceramic mugs",
"quantity": 24,
"cargoType": "General" }
],
"shipmentType": "CONSOLIDATED",
"serviceType": "AIR_CARGO",
"inspection": true
}'{
"order": {
"id": "ord_cml0…",
"orderNumber": "ORD-8F3KQ2LZ",
"externalOrderId": "SO-1042",
"state": "awaiting_parcels",
"inspection": true,
"parcels": [
{ "trackingNumber": "SF1234567890",
"productName": "Ceramic mugs",
"quantity": 24,
"state": "pending_receipt" }
],
"receivingInstructions": {
"warehouse": "…",
"address": "…",
"postalCode": "…",
"note": "Include the order number on every domestic parcel label."
},
"createdAt": "2026-07-21T02:00:00.000Z",
"updatedAt": "2026-07-21T02:00:00.000Z"
}
}Requires an Idempotency-Key header. Replaying the same key with the same body returns the stored response; a different body returns 409 merchant_idempotency_conflict.
A tracking number that already belongs to another active order returns 409 merchant_parcel_conflict.
serviceType: EXPRESS | AIR_CARGO | BULK_SHIPPING. cargoType: General | Electronics | Liquid | Branded.
List orders
GET/api/v1/merchant/orders
Cursor-paginated list of your orders, newest first. Filter by externalOrderId, state, or updatedSince (ISO 8601).
curl "https://porter.woolii.com/api/v1/merchant/orders?limit=20&state=quote_ready" \
-H "Authorization: Bearer wp_sk_test_..."{
"orders": [ { "id": "ord_cml0…", "state": "quote_ready", "…": "…" } ],
"nextCursor": "ref_cml1…"
}Pass nextCursor back as ?cursor= to fetch the next page. limit is capped at 100.
Retrieve an order
GET/api/v1/merchant/orders/{id}
Returns one order with its parcels and receiving instructions. Order ids from another account return 404 — resource existence is never disclosed across accounts.
curl https://porter.woolii.com/api/v1/merchant/orders/ord_cml0… \
-H "Authorization: Bearer wp_sk_test_..."{ "order": { "id": "ord_cml0…", "state": "processing", "…": "…" } }Cancel an order
POST/api/v1/merchant/orders/{id}/cancel
Cancels an order while it is still awaiting parcels (state awaiting_parcels). After receiving has begun the API returns 409 merchant_order_conflict with a human escalation path — the same rule the customer dashboard enforces.
curl -X POST https://porter.woolii.com/api/v1/merchant/orders/ord_cml0…/cancel \
-H "Authorization: Bearer wp_sk_test_..." \
-H "Idempotency-Key: cancel-SO-1042-v1"{ "order": { "id": "ord_cml0…", "state": "cancelled", "…": "…" } }Retrieve the quote state
GET/api/v1/merchant/orders/{id}/quote
Distinguishes the pending packed quote from the final packed quote. Amounts appear only after packing and measurement produce the final quote — an estimate is never presented as a charge.
curl https://porter.woolii.com/api/v1/merchant/orders/ord_cml0…/quote \
-H "Authorization: Bearer wp_sk_test_..."{
"quote": {
"stage": "final_packed_quote",
"currency": "USD",
"amounts": { "shipping": 84.20, "protectivePackaging": 3.00,
"insurance": 0, "total": 87.20 }
}
}stage: pending_packed_quote | final_packed_quote | unavailable.
Retrieve tracking
GET/api/v1/merchant/orders/{id}/tracking
Normalized provider tracking for every outbound package on the order — the same events the Woolii order workflow records, including automatically re-registered tracking numbers.
curl https://porter.woolii.com/api/v1/merchant/orders/ord_cml0…/tracking \
-H "Authorization: Bearer wp_sk_test_..."{
"state": "in_transit",
"externalOrderId": "SO-1042",
"shipments": [
{ "trackingNumber": "1Z999…", "status": "in_transit",
"events": [ { "description": "Departed facility", "time": "…" } ] }
]
}Create a webhook endpoint
POST/api/v1/merchant/webhook-endpoints
Registers an HTTPS endpoint for the event types you select. The signing secret is returned exactly once — store it immediately. Up to 5 active endpoints per account.
curl https://porter.woolii.com/api/v1/merchant/webhook-endpoints \
-H "Authorization: Bearer wp_sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/woolii",
"eventTypes": ["parcel.received", "quote.ready",
"shipment.dispatched", "shipment.delivered"]
}'{
"endpoint": { "id": "we_cml2…", "url": "https://example.com/webhooks/woolii",
"eventTypes": ["parcel.received", "quote.ready",
"shipment.dispatched", "shipment.delivered"],
"status": "ACTIVE", "createdAt": "…" },
"signingSecret": "wp_whsec_…"
}List webhook endpoints
GET/api/v1/merchant/webhook-endpoints
Lists your endpoints with status and failure counters. Signing secrets are never returned again.
curl https://porter.woolii.com/api/v1/merchant/webhook-endpoints \
-H "Authorization: Bearer wp_sk_test_..."{ "endpoints": [ { "id": "we_cml2…", "status": "ACTIVE", "failureCount": 0, "…": "…" } ] }Disable a webhook endpoint
DELETE/api/v1/merchant/webhook-endpoints/{id}
Disables the endpoint. The delivery ledger is evidence and is retained; nothing is deleted.
curl -X DELETE https://porter.woolii.com/api/v1/merchant/webhook-endpoints/we_cml2… \
-H "Authorization: Bearer wp_sk_test_..."{ "endpoint": { "id": "we_cml2…", "status": "DISABLED" } }Webhook events
Deliveries are at-least-once with bounded backoff (1 minute to 24 hours, 8 attempts); deduplicate by eventId. Sustained failures disable the endpoint, and the delivery ledger is always retained.
parcel.receivedA domestic supplier parcel was scanned and recorded at the warehouse.
inspection.completedAn inspection you requested finished.
quote.readyPacking and measurement produced the final packed quote; the order moved to payment.
shipment.dispatchedCarrier events confirm the shipment entered the provider network.
tracking.updatedReserved for future granular tracking pushes; poll GET tracking today.
shipment.exceptionAn exception was recorded against the shipment.
shipment.deliveredEvery outbound package on the order has a delivered carrier fact.
{
"eventId": "evt_…",
"eventVersion": 1,
"eventType": "quote.ready",
"merchantAccountId": "ma_…",
"occurredAt": "2026-07-21T02:10:00.000Z",
"orderId": "ord_cml0…",
"externalOrderId": "SO-1042",
"data": { "total": 87.20, "shipping": 84.20, "currency": "USD" }
}Verifying signatures
Every delivery is signed with your endpoint's secret: an HMAC-SHA256 over `${timestamp}.${rawBody}`. Headers: X-Woolii-Event-Id, X-Woolii-Timestamp (unix seconds), X-Woolii-Signature (v1=<hex>). Verify against the raw body before parsing.
import { createHmac, timingSafeEqual } from "crypto";
export function verifyWebhook(rawBody, headers, secret) {
const timestamp = headers["x-woolii-timestamp"];
const received = (headers["x-woolii-signature"] || "").replace(/^v1=/, "");
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`, "utf8")
.digest("hex");
return received.length === expected.length &&
timingSafeEqual(Buffer.from(received, "hex"), Buffer.from(expected, "hex"));
}Get access
API access is tied to your WooliiPorter account: register (or sign in), open the developer console, and apply with two fields — your store URL and expected monthly volume. You can browse the console before approval; key creation unlocks once the application is approved, and we notify you by email.
Apply for API accessOpen the developer console
Already approved? The developer console in your dashboard manages sandbox keys and webhook endpoints.