v1 · Public documentation
https://api.kestrelwms.com/v1All endpoints are relative to this base URL. Use HTTPS only — HTTP requests are rejected.
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.
# Pass the key on every request
curl "https://api.kestrelwms.com/v1/orders" \
-H "X-API-Key: gl_live_YOUR_KEY_HERE"Keys are issued with one or more scopes. Calling an endpoint without the required scope returns 403 Forbidden.
orders:readRead 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:writeCreate 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:readRead inventory
Sync current stock levels for your SKUs (read-only).
labels:writeUpload labels
Upload your own carrier shipping labels to outbound orders.
returns:readRead returns
List and view your RMAs, including per-line inspection outcomes — what the warehouse counted, in what condition, and where it went.
returns:writeAuthorise 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.
Every order requires an items array. Each item has:
| Field | Type | Required | Notes |
|---|---|---|---|
productId | UUID string | optional | ID of the product to include. Send this OR sku - at least one. If both are sent, productId wins and sku is ignored. |
sku | string | optional | max 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. |
qty | integer | required | min 1, max 1000000 ·Quantity of units. |
unitWeight | number | required | min 0, max 20000 ·Weight per unit in lbs. |
lineNote | string | optional | max 500 ·Line-level note for this specific item. |
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
| Field | Type | Required | Notes |
|---|---|---|---|
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" | optional | Outbound: 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. |
customerId | UUID string | key-dependent | Required for tenant-level keys. Auto-filled from the key for customer-scoped keys. |
warehouseId | UUID string | optional | An 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). |
referenceNumber | string | 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. |
notes | string | optional | max 2000 ·Order-level notes visible to warehouse staff. |
items | OrderItem[] | 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. |
freightRouting | FreightRouting | null | optional | Outbound 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. |
shipToName | string | optional | max 200 ·Outbound: recipient name. |
shipToAddress | string | optional | max 200 ·Outbound: street address line 1. |
shipToAddress2 | string | optional | max 200 ·Outbound: suite, unit, or floor. |
shipToCity | string | optional | max 100 ·Outbound: destination city. |
shipToState | string | optional | max 100 ·Outbound: state or province code (e.g. "TX"). |
shipToZip | string | optional | max 20 ·Outbound: postal code. |
shipToCountry | string | optional | max 2 ·Outbound: ISO 3166-1 alpha-2 country code. Defaults to "US". |
packages | Package[] | 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. |
eta | date string (YYYY-MM-DD) | optional | Inbound: 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. |
containerNumbers | string | optional | max 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. |
deliveryCarrier | string | optional | max 120 ·Inbound: who delivers to the warehouse. |
senderName | string | optional | max 200 ·Inbound: shipper name. |
senderCompany | string | optional | max 200 ·Inbound: shipper company. |
senderAddress | string | optional | max 200 ·Inbound: shipper street address. |
senderAddress2 | string | optional | max 200 ·Inbound: shipper address line 2. |
senderCity | string | optional | max 100 ·Inbound: shipper city. |
senderState | string | optional | max 100 ·Inbound: shipper state or province. |
senderZip | string | optional | max 20 ·Inbound: shipper postal code. |
senderCountry | string | optional | max 2 ·Inbound: shipper country, two-letter code. |
senderPhone | string | optional | max 40 ·Inbound: shipper phone. |
senderEmail | email string | optional | max 254 ·Inbound: shipper email. |
senderIsResidential | boolean | optional | Inbound: the sender address is residential. |
OrderItem
| Field | Type | Required | Notes |
|---|---|---|---|
productId | UUID string | optional | ID of the product to include. Send this OR sku - at least one. If both are sent, productId wins and sku is ignored. |
sku | string | optional | max 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. |
qty | integer | required | min 1, max 1000000 ·Quantity of units. |
unitWeight | number | required | min 0, max 20000 ·Weight per unit in lbs. |
lineNote | string | optional | max 500 ·Line-level note for this specific item. |
Package (inbound carton type)
| Field | Type | Required | Notes |
|---|---|---|---|
packageName | string | required | max 120 ·Your box or carton number for this carton type. Printed on the carton marks. |
packageQty | integer | required | min 1, max 10000 ·How many identical cartons of this type are coming. |
lines | PackageLine[] | required | min 1, max 500 ·The SKUs ONE carton of this type holds (see PackageLine below). |
dimensions | PackageDimensions | optional | One carton's outside dimensions (see PackageDimensions below). Omitted, or any side 0, means not measured. |
weight | number | optional | min 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. |
extraCartonMark | string | optional | max 500 ·Your own printed label or barcode on these cartons. |
note | string | optional | max 2000 ·Note for this carton type. |
PackageLine (SKU in one carton)
| Field | Type | Required | Notes |
|---|---|---|---|
productId | UUID string | optional | Send this OR sku, exactly as on an order item. If both are sent, productId wins. |
sku | string | optional | max 100 ·Your own SKU, exactly as GET /v1/inventory reports it. Case-sensitive; the product must be active. |
qtyPerCase | integer | required | min 1, max 1000000 ·Units of this SKU in ONE carton of this type. |
lotNumber | string | optional | max 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. |
expiryDate | date string (YYYY-MM-DD) | optional | The lot's expiry date. Applied only if we track the SKU's expiry; ignored otherwise. |
note | string | optional | max 500 ·Note for this line. |
PackageDimensions
| Field | Type | Required | Notes |
|---|---|---|---|
l | number | required | min 0, max 300 ·Length. |
w | number | required | min 0, max 300 ·Width. |
h | number | 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)
| Field | Type | Required | Notes |
|---|---|---|---|
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. |
assignedByName | string | optional | max 100 ·Who that is, for people to read, e.g. "Lowe's". |
carrier | FreightRoutingCarrier | null | optional | The 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" | optional | Who pays the freight: the shipper (prepaid), the buyer (collect) or the party in billTo (third_party). Printed on the BOL. |
billTo | FreightRoutingAddress | null | conditional | Who the carrier bills (see FreightRoutingAddress below). Only with freightTerms "third_party"; a 400 with any other terms. |
loadNumber | string | optional | max 64 ·The buyer's load, shipment or routing number. Printed on the BOL as Ref #. |
pickup | FreightRoutingPickup | null | optional | The booked pickup, when there is one (see FreightRoutingPickup below). |
instructions | string | optional | max 2000 ·Routing instructions for the warehouse. Printed on the BOL as special instructions. |
FreightRoutingCarrier
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | conditional | max 120 ·REQUIRED when carrier is sent. The carrier (truck company) name. |
scac | string | optional | 2 to 4 letters ·The carrier's SCAC. Upper-cased on the way in. |
phone | string | optional | max 40 ·The carrier's phone, for booking the pickup. |
FreightRoutingPickup
| Field | Type | Required | Notes |
|---|---|---|---|
date | date string (YYYY-MM-DD) | optional | The scheduled pickup date. |
reference | string | optional | max 64 ·Pickup appointment or confirmation number. |
FreightRoutingAddress (bill-to)
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | optional | max 120 ·Bill-to name. |
company | string | optional | max 120 ·Bill-to company. |
address | string | optional | max 200 ·Street address. |
address2 | string | optional | max 200 ·Address line 2. |
city | string | optional | max 100 ·City. |
state | string | optional | max 60 ·State or province. |
zip | string | optional | max 20 ·Postal code. |
country | string | optional | max 60 ·Country. |
phone | string | optional | max 40 ·Phone. |
Example request
# 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
{
"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
All endpoints are customer-scoped — you can only access your own account's data.
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).
All error responses share the same JSON shape:
{
"statusCode": 400,
"message": "items[0].qty must not be less than 1",
"error": "Bad Request"
}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.
Unauthorized
The X-API-Key header is missing, malformed, revoked, or expired. Check the header name first — it is not an Authorization bearer token.
Forbidden
The key is valid but lacks the scope this endpoint requires, or the action is not allowed in the resource's current state.
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.
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.
Payload Too Large
An uploaded file exceeded 10MB.
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.
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.
Internal Server Error
Unexpected server error. Include the x-request-id response header when you report it.