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.

Apply for API access

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.

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

Drive a sandbox order and receive the matching signed webhook
# 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.delivered

Simulation 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 shape
{
  "error": {
    "code": "merchant_order_conflict",
    "message": "Human-readable summary",
    "requestId": "req_…",
    "details": {}
  }
}
401merchant_auth_missing / merchant_auth_invalid

Missing or unknown API key.

401merchant_key_revoked / merchant_key_expired

The key was revoked or has passed its expiry.

403merchant_scope_missing

The key lacks the scope this endpoint requires.

403merchant_account_inactive

The merchant account is not active.

404merchant_order_not_found

No such resource in your account. Cross-account ids also return 404.

409merchant_idempotency_conflict

The Idempotency-Key was already used with a different request body.

409merchant_external_order_exists

externalOrderId already maps to an order (id included in details).

409merchant_parcel_conflict

A tracking number already belongs to an active order.

409merchant_order_conflict

The order is past the stage this action allows.

422merchant_validation_failed

Field validation failed; details.issues lists each problem.

422merchant_webhook_url_rejected

The webhook URL is not https or resolves to a private/loopback/metadata address.

429merchant_rate_limited

Per-key request budget exceeded. Honor the Retry-After header.

429merchant_sandbox_cap

Daily sandbox order limit reached for this account.

Create an order pre-alert

POST/api/v1/merchant/orders

Scope orders:write · requires Idempotency-Key

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.

Request
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
  }'
Response
{
  "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

Scope orders:read

Cursor-paginated list of your orders, newest first. Filter by externalOrderId, state, or updatedSince (ISO 8601).

Request
curl "https://porter.woolii.com/api/v1/merchant/orders?limit=20&state=quote_ready" \
  -H "Authorization: Bearer wp_sk_test_..."
Response
{
  "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}

Scope orders:read

Returns one order with its parcels and receiving instructions. Order ids from another account return 404 — resource existence is never disclosed across accounts.

Request
curl https://porter.woolii.com/api/v1/merchant/orders/ord_cml0… \
  -H "Authorization: Bearer wp_sk_test_..."
Response
{ "order": { "id": "ord_cml0…", "state": "processing", "…": "…" } }

Cancel an order

POST/api/v1/merchant/orders/{id}/cancel

Scope orders:write · requires Idempotency-Key

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.

Request
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"
Response
{ "order": { "id": "ord_cml0…", "state": "cancelled", "…": "…" } }

Retrieve the quote state

GET/api/v1/merchant/orders/{id}/quote

Scope orders:read

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.

Request
curl https://porter.woolii.com/api/v1/merchant/orders/ord_cml0…/quote \
  -H "Authorization: Bearer wp_sk_test_..."
Response
{
  "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

Scope tracking:read

Normalized provider tracking for every outbound package on the order — the same events the Woolii order workflow records, including automatically re-registered tracking numbers.

Request
curl https://porter.woolii.com/api/v1/merchant/orders/ord_cml0…/tracking \
  -H "Authorization: Bearer wp_sk_test_..."
Response
{
  "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

Scope webhooks:write

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.

Request
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"]
  }'
Response
{
  "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

Scope webhooks:write

Lists your endpoints with status and failure counters. Signing secrets are never returned again.

Request
curl https://porter.woolii.com/api/v1/merchant/webhook-endpoints \
  -H "Authorization: Bearer wp_sk_test_..."
Response
{ "endpoints": [ { "id": "we_cml2…", "status": "ACTIVE", "failureCount": 0, "…": "…" } ] }

Disable a webhook endpoint

DELETE/api/v1/merchant/webhook-endpoints/{id}

Scope webhooks:write

Disables the endpoint. The delivery ledger is evidence and is retained; nothing is deleted.

Request
curl -X DELETE https://porter.woolii.com/api/v1/merchant/webhook-endpoints/we_cml2… \
  -H "Authorization: Bearer wp_sk_test_..."
Response
{ "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.received

A domestic supplier parcel was scanned and recorded at the warehouse.

inspection.completed

An inspection you requested finished.

quote.ready

Packing and measurement produced the final packed quote; the order moved to payment.

shipment.dispatched

Carrier events confirm the shipment entered the provider network.

tracking.updated

Reserved for future granular tracking pushes; poll GET tracking today.

shipment.exception

An exception was recorded against the shipment.

shipment.delivered

Every outbound package on the order has a delivered carrier fact.

Event payload
{
  "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.

Node.js verification
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.