> Part of [WHCC Developer Documentation](https://www.whcc.com/developer/llms.txt)

# Orders & Fulfillment

Version 1.7 &middot; Updated 2026-10-09

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](https://www.whcc.com/developer/docs/api/submission-lifecycle/index.html.md) for the whole path.

## Base URL

- **`https://apis-stage.whcc.com/api/v1/fulfillment`**: Sandbox. The examples on this page use this host.
- **`https://apis.whcc.com/api/v1/fulfillment`**: 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](https://www.whcc.com/developer/docs/api/versioning/index.html.md).

## Authentication

Send your API key as a bearer token:

```shell
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](https://www.whcc.com/developer/docs/api/fulfillment/#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`.

## Operations

| 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](https://www.whcc.com/developer/docs/api/event-subscriptions/index.html.md). 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.

## Listing orders

`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" }] }`.

## Reading an order

`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.fulfillment`**: 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.billing`**: 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.

## Canceling an order

> **Cancellation is irreversible and issues a real refund.** It takes two calls on purpose. Nothing is canceled until the second one.

1. `POST /orders/{id}/cancel-actions` returns a single-use `token`.
2. `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](https://www.whcc.com/developer/docs/api/fulfillment/#order).

## Changing where an order ships

> **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}/shipping`**: 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}/shipping/options`**: The options the order can move to, priced from your account's rates at the time of the order.
- **`POST /orders/{id}/shipping/preview`**: 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}/shipping`**: 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 request body

The preview and the `PUT` take the same body: the whole new address as `shipTo`, and optionally the option to move to.

```shell
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 `PUT`

Send 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.

### Availability

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:

```plaintext
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.

## Reading your account

`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.

```json
{
	"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.payment`**: 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.tax`**: 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.

## Not available to API keys

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.

## Refusals

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

```plaintext
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](https://www.rfc-editor.org/rfc/rfc9457)`application/problem+json` and names the cause:

```plaintext
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](https://www.whcc.com/developer/docs/api/errors/#other-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 |

> ## What's Next
>
> To have WHCC send order events to your own endpoint, see [Event Subscriptions](https://www.whcc.com/developer/docs/api/event-subscriptions/index.html.md). Placing orders is covered in the [Quick Start](https://www.whcc.com/developer/docs/quick-start/index.html.md). For how a submission turns into the orders you read here, see [Submission Lifecycle](https://www.whcc.com/developer/docs/api/submission-lifecycle/index.html.md).