Read and manage orders you've already placed: their status, line items, where they ship, and cancellation. Same host, same API key: these routes live under /v1/fulfillment.
If you placed the order through POST /v1/commerce/submissions, start from the submission. GET /v1/commerce/submissions/{submissionId} tells you whether it produced orders. Each entry in its orders[] carries an actions.fulfillment.url that returns the matching fulfillment orders here. Follow an item's links.self for its current status, statusHistory, and shipments. See Submission Lifecycle for the whole path.
https://
Sandbox. The examples on this page use this host.
https://
Production. Orders here are real, and a shipping change here changes what you're billed.
Every path on this page is relative to that prefix, so /orders means https://apis-stage.whcc.com/api/v1/fulfillment/orders. Send WHCC-API-Version: 2026-10 on every request; see API Versions.
Send your API key as a bearer token:
1
2
curl https://apis-stage.whcc.com/api/v1/fulfillment/orders \
-H "Authorization: Bearer <YOUR_API_KEY>"
Your key needs the apikey scope. Keys WHCC issues carry it by default. If yours doesn't, these routes return 403. See Refusals.
You never send an account identifier. WHCC resolves your account from the key itself, and no field, header, or query parameter can point a key at another account. An order that isn't yours returns 404.
| Method | Path | What it does |
|---|---|---|
GET | /orders | List your orders |
GET | /orders/{id} | One order's detail and status, by fulfillment order id or WHCC order number |
GET | /orders/configured-products?sourceOrderId=… | The configured products on every order from one submission |
GET | /orders/{id}/configured-products | Configured products on one order |
GET | /orders/{id}/shipping | Where an order ships and which option carries it |
GET | /orders/{id}/shipping/options | The options the order can move to, priced for your account |
POST | /orders/{id}/shipping/preview | Dry-run a shipping change without saving anything |
PUT | /orders/{id}/shipping | Change where an order ships and which option carries it |
POST | /orders/{id}/cancel-actions | Begin a cancellation and receive a single-use token |
POST | /orders/{id}/cancellation | Complete the cancellation with that token |
GET | /account | Your account's name, addresses, payment terms, and tax status |
These are the routes an API key can call, along with Event Subscriptions. Parameters, response schemas, and status codes for each are in the OpenAPI spec at GET /openapi.yaml, served without authentication. Routes open to API keys are marked x-whcc-api-key: true there. If this page and the spec ever disagree, the spec is right.
GET /orders returns your orders newest first as { "items": [...], "pagination": {...} }. Filter and page with these query parameters. Any other name returns 400.
sourceOrderId
Orders produced by one submission. This is the filter a submission's actions.fulfillment link uses.
sourceConfirmationId
Orders for one orders[].confirmationId on a submission.
reference, search
Orders carrying that exact reference, or matching a free-text search. search matches part of a value, including part of the reference.
receivedAfter, receivedBefore
Orders WHCC received after or before an ISO 8601 timestamp.
limit, before, after
Page size from 1 to 100 (default 25), and the cursors to page older (before) or newer (after). Take them from pagination.nextBefore and pagination.nextAfter, and treat them as opaque.
There is no orderNumber filter: it returns 400 like any other unknown name. To read an order by its number, call GET /orders/{orderNumber}.
GET /orders/configured-products requires sourceOrderId and returns, for each order from that submission, the configuredProductId values it holds: { "items": [{ "orderId", "orderNumber", "sourceOrder", "configuredProducts": [{ "configuredProductId" }] }] }. GET /orders/{id}/configured-products returns the same ids for one order as { "items": [{ "configuredProductId" }] }.
GET /orders/{id} takes a fulfillment order id (FO_ followed by 14 characters, case-sensitive) or the numeric WHCC order number. A well-formed id that isn't an order on your account returns 404 with a detail of Order not found: …. Anything else in that position, such as a confirmation id, a submission id, or an FO_ id of the wrong length, returns a 404 whose detail begins No route matches.
orderNumber
The WHCC order number, the one on the packing slip. A submission's orders[] carries the same number once WHCC has received the order.
status, statusAt, statusHistory[]
The order's current status as text for people to read, such as "Order Received", "Queued for Printing", "Shipped", or "Order Cancelled", with when it was reached and the statuses before it. New status text can appear at any time, so don't branch on it. Branch on progress.
progress.
How far production has got, as a fixed set of values: pending (accepted, waiting to be produced), in_progress, produced, ready_for_collection, fulfilled (handed to a carrier or collected, not a delivery confirmation), blocked (on hold; status says why), cancel_requested, canceled, none, and unknown (a new status this list doesn't classify yet). It describes the latest status, and production can move backward, so redraw from the current value.
progress.
Where the order's invoice stands: pending, processing, complete, or canceled. It doesn't say whether the order was paid. lifecycle carries the same value and is being retired, so read this one.
deliveryLocation
Where the order was asked to go: dropship (to your customer), account (to you), or pickup.
cancelEligibility
{ eligible, reason, status, ineligibleAt }. When eligible is false, reason is already_canceled, cancellation_already_requested, order_completed (production went too far; status names the step), not_a_cancelable_kind, or order_not_found. New reasons can appear, so treat one you don't know as "can't be canceled".
shipments[]
One entry per package once it ships, with carrier, shippingMethod, trackingNumber, trackingUrl, and the address it went to.
reference
The reference from your submission, or null when you didn't send one. (The submission itself returns "".)
sourceOrder, productType, shippingFrom, shippingMethod, estimatedCompleteDate
The order also carries these, along with occurredAt and lastConfirmedAt on each statusHistory[] entry and sku on the financials items. They aren't part of the documented contract and can change, so don't build on them. Read status and progress for where the order stands.
links
Links to related reads, such as self and cancelActions. Each is a path on the host, such as /api/v1/fulfillment/orders/FO_…, so add the host you called. This differs from actions on a submission, which are absolute URLs.
Timestamps on fulfillment orders carry the lab's UTC offset, such as 2026-02-06T13:38:34.348-06:00. Submission timestamps are UTC.
Cancellation is irreversible and issues a real refund. It takes two calls on purpose. Nothing is canceled until the second one.
POST /orders/{id}/cancel-actions returns a single-use token.POST /orders/{id}/cancellation with that value as cancelToken in the body performs the cancellation.Treat the token as opaque. It works exactly once and expires after a few minutes. To retry, start again from the first call. A successful cancellation returns 200 with { id, orderNumber, outcome: "canceled" }.
order_not_cancelable
422 on cancel-actions. The order can't be canceled, and cancelEligibility.reason says why.
cancel_token_invalid
400 on cancellation. The cancelToken is missing, malformed, expired, or was issued for another order. Start again from cancel-actions.
cancel_token_already_used
409 on cancellation. The token already canceled the order. Read the order to confirm.
Whether an order can be canceled depends on the order, not the environment. An order's response includes links.cancelActions only while it is cancelable, so if you follow links you'll never be offered a cancellation that would be refused. Asking anyway is safe: on an order that can't be canceled, the first call returns no token, just a 422 with code order_not_cancelable and a cancelEligibility object that explains why. The same object appears on order detail.
On this route an id that names no order on your account returns 422 with cancelEligibility.reason of order_not_found, not a 404. Reading the same id with GET /orders/{id} returns 404. An id that isn't shaped like an order id or order number returns 404 here too. See Reading an order.
Only third-party-billed orders can change shipping. That means orders billed to your own carrier account. On any other order, the options read, the preview, and the PUT return 409 with code not-third-party-billed, once the request itself is valid. A PUT or preview with a bad address returns 422 first. The shipping read still works on every order, and its thirdPartyBilled field tells you in advance.
Four routes cover one resource:
GET /orders/{id}/
Where the order ships and which option carries it. Returns the ETag you need for a change. shipTo has attn, name, addr1 to addr3, city, state, zip, country and phone. It has no email, which the submission's shippingAddress does carry, so a ship-to email you sent can't be read back here. If you sent no phone number, shipTo.phone is the account's phone number from GET /account, not null.
GET /orders/{id}/
The options the order can move to, priced from your account's rates at the time of the order.
POST /orders/{id}/
Takes the same body as the PUT and reports what it would do: the options at the new address, the new amount owed, or the refusal it would meet. Saves nothing. If-Match is ignored here.
PUT /orders/{id}/
Replaces the address and option. Changing the option changes what you're billed.
Preview first, then send the ETag the preview returned as the PUT's If-Match header. That way you write exactly what you previewed.
The preview and the PUT take the same body: the whole new address as shipTo, and optionally the option to move to.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
curl -X PUT https://apis-stage.whcc.com/api/v1/fulfillment/orders/<orderId>/shipping \
-H "Authorization: Bearer <YOUR_API_KEY>" \
-H "Content-Type: application/json" \
-H 'If-Match: <ETAG>' \
-d '{
"shipTo": {
"attn": "Dana Whitfield",
"name": "Whitfield Studio",
"addr1": "64 Dikeman St",
"city": "Brooklyn",
"state": "NY",
"zip": "11231",
"country": "US"
},
"shippingOptionId": "<id from GET /orders/{id}/shipping/options>"
}'
shipTo
object, required
The full address. A field you leave out is cleared, so send everything you want kept. addr1, city, zip and country are required, plus at least one of attn (the person) and name (the business). A US address needs a 2-letter state and a zip of at least 5 characters. addr2, addr3 and phone are optional. Address lines, attn and name hold up to 50 characters, and city up to 35. A change can't move the order to another country.
shippingOptionId
string, optional
An id from GET /orders/{id}/shipping/options. Leave it out to keep the option the order already holds.
Any other key in the body returns 400. A bad address returns 422 with invalidParams[], such as shipTo.city with the reason Required.. That check runs before the third-party-billing check, so a bad address gets 422 even on an order that can't change shipping, and 409 only follows a body that is valid. A successful PUT returns 200 with the same shape as GET /orders/{id}/shipping (shipTo, shippingOptionId, shippingCharge, thirdPartyBilled, deliveryLocation) and the new ETag.
If-Match is required on the PUTSend the ETag from GET /orders/{id}/shipping back unchanged. It's opaque, so don't parse or construct one. A PUT without it returns 428 (if-match-required). One with a stale value returns 412 (etag-mismatch), and nothing is written. Both are checked before the third-party-billing check, so they come back even on an order that can't change shipping. They are not checked before the order lookup: an id that names no order returns 404 first. A successful PUT returns the next ETag, so you can make a second change without re-reading. The options list carries no ETag. Its current marker is only a hint, so read GET /orders/{id}/shipping to know which option an order really holds.
The shipping read always answers. WHCC can pause shipping changes, and while they're paused the options read, the preview, and the PUT all return:
1
2
3
503 Service Unavailable
{"type":"about:blank","title":"Service Unavailable","status":503,
"detail":"Shipping changes are temporarily unavailable."}
That response carries no Retry-After and retrying won't help. A 503 on the PUT with code shipping_change_not_begun is different: the request ran out of time before anything was sent, and you can retry the identical request right away.
GET /account returns the account your key belongs to: who it is, where it ships and bills, and how it pays. Use it to confirm which account a key is bound to, or to show the studio's own address at checkout.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
"id": "ACCT_vY9KecqAFCwFuY",
"accountId": "whcc_88888",
"accountLabel": "88888",
"brand": "WHCC",
"name": "Brown Photography",
"contact": "Doc Brown",
"emails": ["doc@example.com"],
"phone": "555-867-5309",
"tags": [],
"addresses": {
"shipping": { "attn": "Doc Brown", "name": "Brown Photography", "addr1": "1640 Riverside Drive", "addr2": null, "city": "Hill Valley", "state": "CA", "zip": "95420", "country": "US" },
"billing": { "…": "the same shape, or null" }
},
"financials": {
"payment": {
"status": "not_requested",
"terms": "Per Order Credit Card",
"methods": [],
"requirements": { "cardAtCheckout": true, "cardOnFile": true }
},
"tax": { "taxable": true, "exemptCertificate": null }
},
"identity": { "organizationId": "org_…", "userId": "user_…" }
}
id, accountId, accountLabel
Three names for the same account. accountId is the one submissions return. accountLabel is the account number to show a person.
name, contact, emails, phone
The studio name and its contact details. contact and phone can be null.
addresses
The account's shipping and billing addresses. Each address repeats some values under a second name (addr1 and line1, zip and postalCode), and carries other keys beyond the ones shown, such as attention, line2 and countryCode. Read the names shown here.
financials.
How the account pays: terms to display, and requirements for whether a card is needed. financials also carries other keys not shown here, such as paymentTerms, activityReports and payment.provider. Read payment.terms and the fields shown, and ignore the rest. methods is empty and status is not_requested unless you add ?expand=paymentMethods, which looks up the saved cards and takes longer.
financials.
Whether the account is charged sales tax, and its exemption certificate if it has one.
identity
Ignore. Ids WHCC's own sign-in system uses. No other route takes them.
API keys can't call GET /orders/{id}/form. It returns WHCC's stored order-form HTML, which isn't a format this API documents or keeps stable.
A missing, expired, or revoked key is a 401. The gateway answers before the request reaches WHCC, with a one-field {"message":"Unauthorized"} body and no Problem Details to parse. Ask WHCC support for a new key.
Two different things return 403, and the body tells them apart.
The gateway rejected the key. It isn't valid for this host or route. The body is minimal:
1
2
403 Forbidden
{"message":"Forbidden"}
The service rejected the request. The key is valid but doesn't carry the scope this route needs. The body is RFC 9457 application/problem+json and names the cause:
1
2
3
4
403 Forbidden
Content-Type: application/problem+json
{"type":"about:blank","title":"Forbidden","status":403,
"detail":"Insufficient scope. Requires: apikey"}
The text after Requires: varies by route and can list more than one scope, so don't match on the whole detail. The words Insufficient scope at the start are the part to check. A path under /v1/fulfillment that isn't a route can also answer 403, not 404.
For the first, fix the key. For the second, contact WHCC to have apikey added. Every other refusal from the service uses the problem+json shape, with a detail and usually a code but no instance field. A path that isn't a route, or a method a route doesn't support, can instead return the framework 404 body described under Other response bodies:
| Status | Content-Type | Meaning |
|---|---|---|
401 | application/json | The key is missing, expired, or revoked |
403 | application/json | The gateway rejected the key |
403 | application/problem+json | The key lacks the required scope |
400 | application/problem+json | An unknown query parameter, a bad cursor or limit, an unknown body field, or a bad cancelToken |
404 | application/problem+json | On a read, the order doesn't exist or isn't on your account |
409 | application/problem+json | not-third-party-billed on a shipping change, or cancel_token_already_used on a cancellation |
412 / 428 | application/problem+json | If-Match was stale or missing on a shipping PUT |
422 | application/problem+json | The request body failed validation, or the order can't be canceled (including an unknown id on cancel-actions) |
503 | application/problem+json | Shipping changes are switched off |
To have WHCC send order events to your own endpoint, see Event Subscriptions. Placing orders is covered in the Quick Start. For how a submission turns into the orders you read here, see Submission Lifecycle.