Kestrel WMS

Partner API Reference

v1 · Public documentation

v1

Base URL

Base URLhttps://api.kestrelwms.com/v1

All endpoints are relative to this base URL. Use HTTPS only — HTTP requests are rejected.

Authentication

Every request must include your API key in the X-API-Key header. Keys are created and managed in your customer portal under API Access. Each key is scoped to specific permissions.

Keep your key secret

Your API key is shown once at creation. Store it in a secrets manager. A SHA-256 hash is the only thing stored on the server — we cannot recover a lost key.

bash
# Pass the key on every request
curl "https://api.kestrelwms.com/v1/orders" \
  -H "X-API-Key: gl_live_YOUR_KEY_HERE"

Scopes

Keys are issued with one or more scopes. Calling an endpoint without the required scope returns 403 Forbidden.

orders:read

Read orders & tracking

List and view your orders, retrieve shipment tracking and what has been received on an inbound order, download order documents, and list the warehouses you can send orders to.

orders:write

Create orders & attach docs

Submit and cancel orders, set the freight routing (the carrier a buyer assigned) on LTL/FTL orders, and upload order attachments (invoices, packing lists, retailer-generated BOLs).

inventory:read

Read inventory

Sync current stock levels for your SKUs (read-only).

labels:write

Upload labels

Upload your own carrier shipping labels to outbound orders.

returns:read

Read returns

List and view your RMAs, including per-line inspection outcomes — what the warehouse counted, in what condition, and where it went.

returns:write

Authorise returns

Raise an RMA against an order, and cancel one that has not arrived yet. Receiving, inspecting, dispositioning and approving stay with the warehouse — those assert what physically happened to the goods.

Order Item Fields

Every order requires an items array. Each item has:

FieldTypeRequiredNotes
productIdUUID stringoptionalID of the product to include. Send this OR sku - at least one. If both are sent, productId wins and sku is ignored.
skustringoptionalmax 100 ·Your own SKU, exactly as GET /v1/inventory reports it. Case-sensitive, and the product must be active. Send this OR productId. At most 100 distinct SKUs per order - a request naming more is refused, not truncated.
qtyinteger
required
min 1, max 1000000 ·Quantity of units.
unitWeightnumber
required
min 0, max 20000 ·Weight per unit in lbs.
lineNotestringoptionalmax 500 ·Line-level note for this specific item.

Endpoints

Click an endpoint to expand

Create an order with status "pending". OUTBOUND: send items[] (at least one line) and a shipmentType. INBOUND, a forecast of goods arriving: send packages[], one entry per carton type with packageQty and the SKU lines ONE carton holds (qtyPerCase), an arrivalType and a referenceNumber; items[] is refused, because the order items are derived from the cartons (units per SKU = packageQty x qtyPerCase, summed). The referenceNumber is what makes a retried forecast safe: a second forecast with the same one is a 409, not a duplicate. A forecast on a container_* arrivalType is ONE container: containerNumbers naming two different containers is a 400, so send one forecast per container. On an OUTBOUND order the inbound-only fields (packages, arrivalType, eta, containerNumbers, deliveryCarrier, sender*) are ignored, as they always were. Blank strings on those fields and on warehouseId count as not sent. warehouseId is optional on both: omitted, the order goes to the warehouse GET /v1/warehouses marks isDefault; a value that is not a UUID is a 400, and one that is not your active warehouse a 422. The lot and expiry on a carton line are applied only to SKUs we track by lot or expiry. A carton weight needs its weightUnit, and carton dimensions need their unit. The order object returned here is the SAME shape GET /v1/orders/:id returns, items included - you can model one type and use it on every order endpoint. Each line identifies its product by productId (our UUID) OR by sku (your own, exactly as GET /v1/inventory reports it) - send at least one. A sku is matched case-sensitively against your ACTIVE catalogue; an unmatched one returns 400 naming every offending line by path (items[i] or packages[i].lines[j]), and never a partially-created order. Sending both on a line is accepted - productId wins and sku is ignored. Limits, refused rather than truncated: 500 items or carton lines per order, 100 distinct SKUs to resolve, 10,000 cartons per order, 1,000,000 units of one SKU, 300 per carton side, 2,000 per carton weight. A forecast over a limit can be split into several orders only when each order is its own container; a single container over one is a matter for your warehouse. The order date is the moment of creation; there is no field for it. A forecast can be cancelled while it is pending and nothing has been received against it; follow its receiving with GET /v1/orders/:id/receipts.

Request body

FieldTypeRequiredNotes
orderType"inbound" | "outbound"
required
Outbound: an order to ship. Inbound: a FORECAST of goods arriving, sent as cartons in packages[].
shipmentType"small_package" | "ltl" | "ftl"optionalOutbound: the carrier-routing mode. Optional in the schema, but send it on every outbound order: one without it is accepted and then never enters the pick, pack and ship steps. Ignored on inbound.
customerIdUUID string
key-dependent
Required for tenant-level keys. Auto-filled from the key for customer-scoped keys.
warehouseIdUUID stringoptionalAn id from GET /v1/warehouses. Optional on both order types; omitted or blank, the order goes to the warehouse that endpoint marks isDefault. Anything else must be a UUID (400) and one of your active warehouses (422).
referenceNumberstring
conditional
max 100 ·Your own order or PO number. Inbound: REQUIRED, and a missing or blank one is a 400. Outbound: optional, and a blank one is not stored. Must be unique across your orders that are not cancelled (inbound and outbound share one namespace): a second one is a 409, which is what stops a retried forecast from creating a second one. Cancelling an order frees it.
notesstringoptionalmax 2000 ·Order-level notes visible to warehouse staff.
itemsOrderItem[]
conditional
min 1, max 500 ·Outbound: REQUIRED, at least one line (see OrderItem below). Inbound: not accepted - send the cartons in packages[] and the items are derived from them.
freightRoutingFreightRouting | nulloptionalOutbound LTL/FTL only (shipmentType "ltl" or "ftl"): the carrier a buyer (a retailer's routing guide, e.g. Lowe's) or you assigned (see FreightRouting below). It is copied onto the order's bill of lading. A non-empty block on any other order is a 400; null or an all-blank block means no routing. Unknown keys inside it are a 400, not dropped. The warehouse may update it later.
shipToNamestringoptionalmax 200 ·Outbound: recipient name.
shipToAddressstringoptionalmax 200 ·Outbound: street address line 1.
shipToAddress2stringoptionalmax 200 ·Outbound: suite, unit, or floor.
shipToCitystringoptionalmax 100 ·Outbound: destination city.
shipToStatestringoptionalmax 100 ·Outbound: state or province code (e.g. "TX").
shipToZipstringoptionalmax 20 ·Outbound: postal code.
shipToCountrystringoptionalmax 2 ·Outbound: ISO 3166-1 alpha-2 country code. Defaults to "US".
packagesPackage[]
conditional
min 1, max 500 ·Inbound: REQUIRED, one entry per carton type (see Package below). Ignored on outbound. At most 10,000 cartons and 500 carton lines across the order, and 1,000,000 units of one SKU (packageQty x qtyPerCase, summed).
arrivalType"small_parcel" | "pallet" | "container_20ft" | "container_40ft" | "container_45ft" | "container_48ft" | "container_53ft" | "other"
conditional
Inbound: REQUIRED. How the goods arrive; it drives the container and pallet receiving fees, so send the real one. Full container: container_20ft / container_40ft / container_45ft (40'HQ is container_40ft). US trailer: container_48ft / container_53ft. LCL, CFS or LTL: pallet. Express or parcel: small_parcel. Anything else: other. A container_* forecast is ONE container: send one forecast per container. Ignored on outbound.
etadate string (YYYY-MM-DD)optionalInbound: expected arrival date. A timestamp is refused, because the date would shift through UTC. The order response returns it as a date-time at midnight UTC.
containerNumbersstringoptionalmax 500 ·Inbound: the container number, e.g. "MSKU1234567". On a container_* arrivalType a forecast is ONE container: two different numbers are a 400, so send one forecast per container. On any other arrivalType several are accepted, as ONE comma-separated string - the same form the order response returns.
deliveryCarrierstringoptionalmax 120 ·Inbound: who delivers to the warehouse.
senderNamestringoptionalmax 200 ·Inbound: shipper name.
senderCompanystringoptionalmax 200 ·Inbound: shipper company.
senderAddressstringoptionalmax 200 ·Inbound: shipper street address.
senderAddress2stringoptionalmax 200 ·Inbound: shipper address line 2.
senderCitystringoptionalmax 100 ·Inbound: shipper city.
senderStatestringoptionalmax 100 ·Inbound: shipper state or province.
senderZipstringoptionalmax 20 ·Inbound: shipper postal code.
senderCountrystringoptionalmax 2 ·Inbound: shipper country, two-letter code.
senderPhonestringoptionalmax 40 ·Inbound: shipper phone.
senderEmailemail stringoptionalmax 254 ·Inbound: shipper email.
senderIsResidentialbooleanoptionalInbound: the sender address is residential.

OrderItem

FieldTypeRequiredNotes
productIdUUID stringoptionalID of the product to include. Send this OR sku - at least one. If both are sent, productId wins and sku is ignored.
skustringoptionalmax 100 ·Your own SKU, exactly as GET /v1/inventory reports it. Case-sensitive, and the product must be active. Send this OR productId. At most 100 distinct SKUs per order - a request naming more is refused, not truncated.
qtyinteger
required
min 1, max 1000000 ·Quantity of units.
unitWeightnumber
required
min 0, max 20000 ·Weight per unit in lbs.
lineNotestringoptionalmax 500 ·Line-level note for this specific item.

Package (inbound carton type)

FieldTypeRequiredNotes
packageNamestring
required
max 120 ·Your box or carton number for this carton type. Printed on the carton marks.
packageQtyinteger
required
min 1, max 10000 ·How many identical cartons of this type are coming.
linesPackageLine[]
required
min 1, max 500 ·The SKUs ONE carton of this type holds (see PackageLine below).
dimensionsPackageDimensionsoptionalOne carton's outside dimensions (see PackageDimensions below). Omitted, or any side 0, means not measured.
weightnumberoptionalmin 0, max 2000 ·One carton's GROSS weight (box and contents), in weightUnit. Omitted, or 0, means not weighed.
weightUnit"lb" | "kg"
conditional
Unit of weight. REQUIRED when weight is above 0; never defaulted.
extraCartonMarkstringoptionalmax 500 ·Your own printed label or barcode on these cartons.
notestringoptionalmax 2000 ·Note for this carton type.

PackageLine (SKU in one carton)

FieldTypeRequiredNotes
productIdUUID stringoptionalSend this OR sku, exactly as on an order item. If both are sent, productId wins.
skustringoptionalmax 100 ·Your own SKU, exactly as GET /v1/inventory reports it. Case-sensitive; the product must be active.
qtyPerCaseinteger
required
min 1, max 1000000 ·Units of this SKU in ONE carton of this type.
lotNumberstringoptionalmax 120 ·Your lot (batch) number for these units. Applied only if we track the SKU by lot (the warehouse records the lot at receipt in any case; this pre-fills it); ignored otherwise. Not returned on the order.
expiryDatedate string (YYYY-MM-DD)optionalThe lot's expiry date. Applied only if we track the SKU's expiry; ignored otherwise.
notestringoptionalmax 500 ·Note for this line.

PackageDimensions

FieldTypeRequiredNotes
lnumber
required
min 0, max 300 ·Length.
wnumber
required
min 0, max 300 ·Width.
hnumber
required
min 0, max 300 ·Height.
unit"in" | "cm"
conditional
REQUIRED when all three sides are above 0; never defaulted. A side of 0 means not measured.

FreightRouting (outbound LTL/FTL)

FieldTypeRequiredNotes
assignedBy"buyer" | "seller"
conditional
REQUIRED in a non-empty block. buyer: a retailer, marketplace or B2B buyer chose the carrier. seller: you did, with your own freight contract.
assignedByNamestringoptionalmax 100 ·Who that is, for people to read, e.g. "Lowe's".
carrierFreightRoutingCarrier | nulloptionalThe assigned carrier (see FreightRoutingCarrier below). Omit it while the buyer has not assigned one: the warehouse then waits rather than booking its own.
freightTerms"prepaid" | "collect" | "third_party"optionalWho pays the freight: the shipper (prepaid), the buyer (collect) or the party in billTo (third_party). Printed on the BOL.
billToFreightRoutingAddress | null
conditional
Who the carrier bills (see FreightRoutingAddress below). Only with freightTerms "third_party"; a 400 with any other terms.
loadNumberstringoptionalmax 64 ·The buyer's load, shipment or routing number. Printed on the BOL as Ref #.
pickupFreightRoutingPickup | nulloptionalThe booked pickup, when there is one (see FreightRoutingPickup below).
instructionsstringoptionalmax 2000 ·Routing instructions for the warehouse. Printed on the BOL as special instructions.

FreightRoutingCarrier

FieldTypeRequiredNotes
namestring
conditional
max 120 ·REQUIRED when carrier is sent. The carrier (truck company) name.
scacstringoptional2 to 4 letters ·The carrier's SCAC. Upper-cased on the way in.
phonestringoptionalmax 40 ·The carrier's phone, for booking the pickup.

FreightRoutingPickup

FieldTypeRequiredNotes
datedate string (YYYY-MM-DD)optionalThe scheduled pickup date.
referencestringoptionalmax 64 ·Pickup appointment or confirmation number.

FreightRoutingAddress (bill-to)

FieldTypeRequiredNotes
namestringoptionalmax 120 ·Bill-to name.
companystringoptionalmax 120 ·Bill-to company.
addressstringoptionalmax 200 ·Street address.
address2stringoptionalmax 200 ·Address line 2.
citystringoptionalmax 100 ·City.
statestringoptionalmax 60 ·State or province.
zipstringoptionalmax 20 ·Postal code.
countrystringoptionalmax 60 ·Country.
phonestringoptionalmax 40 ·Phone.

Example request

bash
# An outbound order
curl -X POST https://api.kestrelwms.com/v1/orders \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "customerId": "cust-uuid",
    "orderType": "outbound",
    "shipmentType": "small_package",
    "referenceNumber": "PO-12345",
    "shipToName": "Jane Smith",
    "shipToAddress": "123 Main St",
    "shipToCity": "Austin",
    "shipToState": "TX",
    "shipToZip": "78701",
    "items": [
      { "productId": "prod-uuid", "qty": 2, "unitWeight": 1.5 },
      { "sku": "MS-BRBN-750", "qty": 1, "unitWeight": 3.2 }
    ]
  }'

# An inbound forecast: 40 cartons of 12 and 10 mixed cartons, in a 40 ft container
curl -X POST https://api.kestrelwms.com/v1/orders \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "orderType": "inbound",
    "referenceNumber": "FC-2026-118",
    "arrivalType": "container_40ft",
    "eta": "2026-11-02",
    "containerNumbers": "MSKU1234567",
    "senderCompany": "Example Factory Co.",
    "senderCountry": "CN",
    "packages": [
      {
        "packageName": "BOX-A",
        "packageQty": 40,
        "dimensions": { "l": 20, "w": 16, "h": 12, "unit": "in" },
        "weight": 18.5,
        "weightUnit": "lb",
        "lines": [{ "sku": "WIDGET-01", "qtyPerCase": 12, "lotNumber": "L-2611", "expiryDate": "2028-01-31" }]
      },
      {
        "packageName": "BOX-B",
        "packageQty": 10,
        "lines": [
          { "sku": "WIDGET-01", "qtyPerCase": 6 },
          { "sku": "WIDGET-02", "qtyPerCase": 6 }
        ]
      }
    ]
  }'

Example response

json
{
  "id": "uuid",
  "orderNumber": "OB-AB12-0001",
  "orderType": "outbound",
  "shipmentType": "small_package",
  "status": "pending",
  "customerId": "cust-uuid",
  "warehouseId": "wh-uuid",
  "transactionDate": "2026-05-28T00:00:00.000Z",
  "referenceNumber": "PO-12345",
  "shipToName": "Jane Smith",
  "shipToAddress": "123 Main St",
  "shipToCity": "Austin",
  "shipToState": "TX",
  "shipToZip": "78701",
  "shipToCountry": "US",
  "shippingMethodMode": "automatic",
  "shippingCarrier": null,
  "shippingService": null,
  "customerTrackingNumber": null,
  "notes": null,
  "eta": null,
  "containerNumbers": null,
  "items": [
    {
      "id": "item-uuid",
      "productId": "prod-uuid",
      "qty": 2,
      "unitWeight": 1.5,
      "lineNote": null
    },
    {
      "id": "item-uuid-2",
      "productId": "prod-uuid-2",
      "qty": 1,
      "unitWeight": 3.2,
      "lineNote": null
    }
  ],
  "tracking": [],
  "createdAt": "2026-05-28T12:00:00.000Z"
}

Responses

201Order created — tracking[] is empty, nothing has shipped yet. On an inbound forecast, items[] is derived from the cartons
400Validation failed — the message array names each field and each line by path (items[i], packages[i].lines[j]). Also: items on an inbound order; no packages, no arrivalType, no referenceNumber, a weight or dimensions without a unit, or more than 10,000 cartons on one; two different containers on a container_* forecast; a warehouseId that is not a UUID; a malformed Idempotency-Key header
404A tenant-level key named a customerId outside your tenant, or the tenant has no active warehouse
401Missing or invalid API key
403Scope not granted — key needs orders:write
409code="reference_number_taken" — that referenceNumber is already on an order of yours that is not cancelled. The body carries conflictingOrder { id, orderNumber, status }: cancel it to free the reference, or send a different one. Do NOT retry the same body
409code="idempotency_key_in_use" — another request with this Idempotency-Key is still running, so this one did NOT execute. Retry the IDENTICAL request after the seconds in the Retry-After header
422warehouseId is not one of your active warehouses (list them with GET /v1/warehouses)

All endpoints are customer-scoped — you can only access your own account's data.

Rate Limits

Burst limit

10 req/s

Short bursts — enforced per API key

Sustained limit

100 req/min

Rolling 60-second window per key

When you hit a rate limit you receive 429 Too Many Requests. The response includes a Retry-After header — seconds to wait, not a timestamp — alongside X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. Those three describe whichever bucket will bite first; the same figures are also sent per bucket as -short (10/second burst) and -medium(60-second sustained).

Error Codes

All error responses share the same JSON shape:

json
{
  "statusCode": 400,
  "message": "items[0].qty must not be less than 1",
  "error": "Bad Request"
}
400

Bad Request

The request failed validation. "message" names each field (or line, by path) and the rule it broke: an array for validation failures, a string for some single refusals. This is what a malformed request returns — not 422.

401

Unauthorized

The X-API-Key header is missing, malformed, revoked, or expired. Check the header name first — it is not an Authorization bearer token.

403

Forbidden

The key is valid but lacks the scope this endpoint requires, or the action is not allowed in the resource's current state.

404

Not Found

The resource does not exist in your tenant, or a customer-scoped key cannot see it. Both cases return 404 rather than 403, so the response cannot be used to probe for another customer's data.

409

Conflict

Several distinct situations share this code, so the body carries a "code" field — branch on that, never on the message text. Some are retryable and some can never be. "order_status_changed" / "return_status_changed": the resource moved between your read and your write, most often a cancel racing the warehouse — re-read it and retry. "idempotency_key_in_use": another request carrying the same Idempotency-Key is still running, so this one did not execute — wait the seconds in the Retry-After header and retry the IDENTICAL request, which will then either replay the response of the first one or run yours. "reference_number_taken": that referenceNumber is on an order of yours that is not cancelled, and the body names it in conflictingOrder — free it or use another. "receiving_started": the warehouse has received against the inbound order you tried to cancel, so only the warehouse can cancel it — retrying can never succeed. "label_already_exists": the warehouse already bought a label; retrying can NEVER succeed until a human voids it, so a client that auto-retries a bare 409 will loop forever.

413

Payload Too Large

An uploaded file exceeded 10MB.

422

Unprocessable Entity

The request was well-formed but a business rule rejected it — a product that is not on the order, a label uploaded to an inbound order, a warehouseId that is not one of your active warehouses, receipts asked of an outbound order. Distinct from 400: the shape was fine, the meaning was not.

429

Too Many Requests

Rate limit exceeded. Retry-After gives the seconds to wait; X-RateLimit-Remaining and X-RateLimit-Reset let you schedule the next window rather than backing off blindly.

500

Internal Server Error

Unexpected server error. Include the x-request-id response header when you report it.