Errors & Rate Limits

Version 1.7 · Updated 2026-10-09

WHCC API errors come in two shapes, and the shape tells you what to fix. A one-field { "message": … } body comes from the gateway in front of the API, which checks the key: the request was rejected before the API saw it. A Problem Details body comes from the API itself and tells you what was wrong with the request. A 401 is always the first kind. On the commerce, catalog and discovery routes (/v1/commerce, /v1/catalog and /v1/discovery), a 400, 404, 409, 410 or 413 is the second. The fulfillment routes and a few other responses differ; see Other response bodies. A 403 can be either, so check the body before deciding what to fix.

Key errors: the { "message": … } body

A missing or unrecognized API key is rejected before your request reaches the API, with a one-field JSON body:

401 Unauthorized

No Authorization header, or one that can't be read. Send Authorization: Bearer <YOUR_API_KEY>.

An API key can also expire or be revoked. The gateway then answers 401 before the request reaches WHCC, with no Problem Details body to parse. Ask WHCC support for a new key.

403 Forbidden

The key isn't recognized, isn't valid for this host (for example, a sandbox key sent to production), or can't use this route. Check you're using the right key for the host.

1
{ "message": "Unauthorized" }
1
{ "message": "Forbidden" }

Because the key is checked first, a request without a key returns 401, not 404, even for a path that doesn't exist. If you see { "message": … }, fix the key before debugging anything else.

API errors: Problem Details (400, 403, 404, 409, 410, 413, 415)

Errors from the API itself are Problem Details, served as application/problem+json, with these fields:

type

string

about:blank on the commerce, catalog, discovery and fulfillment routes, with one exception: a request for an API version that doesn't exist has the type https://developer.whcc.com/problems/unsupported-api-version. Don't build on about:blank. Some responses outside those routes send an empty string; see Other response bodies.

title

string

A human-readable summary of the problem.

detail

string, sometimes present

More explanation, when there is something to say beyond the title. On a validation 400 it is often a generic line such as Invalid request payload input. The specifics are in invalidParams[].

status

number

The HTTP status code.

instance

string

The request path that produced the error. Errors from the fulfillment routes don't carry it.

invalidParams[]

array, sometimes present

Present on most 400 responses. One { name, reason } entry for each field that failed, where name is the dotted path, such as items.0.quantity. Some entries also carry the value you sent.

code

string, sometimes present

Build your error handling on this field. It's present on the errors listed in the code table below. Other errors don't have one, and that's normal. New codes can be added over time, so treat one you don't recognize as a new case to handle, not a breaking change.

400: the request didn't validate

1
2
3
4
5
6
7
8
9
10
11
12
{
	"type": "about:blank",
	"title": "Your request parameters didn't validate.",
	"status": 400,
	"invalidParams": [
		{
			"reason": "'items[0].quantity' must be a number",
			"name": "items.0.quantity"
		}
	],
	"instance": "/api/v1/commerce/submissions"
}

400: shipping.type and shipping.priority disagree

A priority belongs to the type in its name, and a pair that disagrees is reported on both fields. The same shape, with one entry, covers an unknown type ('ground' isn't a shipping type. Send studio or dropship.) and a malformed priority ('nope' isn't a shipping priority. Call calculate with an address and pick one from options.shipping[].):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
	"type": "about:blank",
	"title": "Your request parameters didn't validate.",
	"status": 400,
	"invalidParams": [
		{
			"name": "shipping.priority",
			"reason": "'studio_two_day' isn't a dropship shipping priority. Send a shipping.priority that matches shipping.type, or change shipping.type."
		},
		{
			"name": "shipping.type",
			"reason": "'dropship' doesn't match shipping.priority: a studio priority needs shipping.type studio."
		}
	],
	"instance": "/api/v1/commerce/submissions"
}

A well-formed priority that the destination doesn't offer is rejected only once you've sent an address, and it carries a code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
	"type": "about:blank",
	"title": "Your request parameters didn't validate.",
	"status": 400,
	"detail": "Invalid request payload input",
	"invalidParams": [
		{
			"name": "shipping.priority",
			"value": "dropship_canada",
			"reason": "'dropship_canada' isn't available for this address. Available: dropship_lowest_cost, dropship_expedited, dropship_one_day_standard, dropship_one_day_priority. To compare prices, call calculate without shipping.priority."
		}
	],
	"instance": "/api/v1/commerce/submissions/calculate",
	"code": "submission.shipping_priority_unsupported"
}

400: the body isn't JSON

A body that can't be parsed as JSON, such as one cut off partway, returns 400 with code: "request.invalid_json" and no invalidParams[]:

1
2
3
4
5
6
7
{
	"type": "about:blank",
	"title": "Your request body isn't valid JSON.",
	"status": 400,
	"instance": "/api/v1/commerce/submissions/calculate",
	"code": "request.invalid_json"
}

Fix the body and send it again. A request whose Content-Type isn't application/json returns 415 with the title Unsupported Media Type and the detail Send the body as application/json. The fulfillment routes, including event subscriptions, don't return 415 for a missing Content-Type: the body is read as JSON. Send application/json anyway.

400: the body contains a reserved key

A body that contains the key __proto__ returns 400 with code: "request.reserved_key" and no invalidParams[]. It applies wherever the key appears in the body, including inside metadata:

1
2
3
4
5
6
7
{
	"type": "about:blank",
	"title": "Your request body contains the reserved key `__proto__`.",
	"status": 400,
	"instance": "/api/v1/commerce/submissions/calculate",
	"code": "request.reserved_key"
}

Remove the key and send the request again.

400: the API version doesn't exist

A WHCC-API-Version header whose value isn't a listed version returns 400 before the route runs. That covers an unlisted month such as 2026-11, and values such as v1, latest or an empty value. The type is https://developer.whcc.com/problems/unsupported-api-version, the detail names the value you sent, and supportedVersions lists every version that exists. The response carries none of the WHCC-API-Version headers, because no version was served. This example is the response to GET /api/v1/catalog/products with the request header WHCC-API-Version: 2026-11:

1
2
3
4
5
6
7
8
{
	"type": "https://developer.whcc.com/problems/unsupported-api-version",
	"title": "Unsupported API version",
	"status": 400,
	"detail": "API version '2026-11' is not supported. Use one of the supported versions.",
	"instance": "/api/v1/catalog/products",
	"supportedVersions": ["2026-10"]
}

Send a version from supportedVersions, or omit the header to get your account's default version. As with other problem bodies from the fulfillment routes, the same error on a fulfillment route has no instance.

403: the key lacks the route's scope

The API returns its own 403, as Problem Details, when your key is valid but doesn't carry the scope this route needs. The words Insufficient scope are in the title on the commerce, catalog and discovery routes and at the start of the detail on the fulfillment routes, so check both.

Which kind of 403 is mine? A { "message": "Forbidden" } body means the key: check it's the right key for this host. A Problem Details body means the key is right but can't use this route: get in touch and we'll sort out the key.

404: submission not found

A submission that belongs to another account returns the same 404 as one that doesn't exist. Check the ID, and that you're using the key it was submitted with:

1
2
3
4
5
6
{
	"type": "about:blank",
	"title": "No submission found with id <unknownSubmissionId>",
	"status": 404,
	"instance": "/api/v1/commerce/submissions/<unknownSubmissionId>"
}

404: product not found

A ref that isn't in your catalog. Refs are case-sensitive, so check the spelling against GET /v1/catalog/products:

1
2
3
4
5
6
{
	"type": "about:blank",
	"title": "Product not found for the supplied reference \"no-such-product\"",
	"status": 404,
	"instance": "/api/v1/catalog/products/no-such-product"
}

A path that isn't a route at all, including a known route with a trailing slash, returns a 404 with the title Not Found and the detail No operation matches this path. That holds on the commerce, catalog and discovery routes. The fulfillment routes accept a trailing slash, so GET /v1/fulfillment/orders/ answers like GET /v1/fulfillment/orders. A method a route doesn't support gets the same 404, not a 405, and the response carries no Allow header.

409: Idempotency-Key already used for a different order

You sent an Idempotency-Key you'd already used, with a different body. The same key with the same body isn't an error: you get your original submission back with a 202. See the Idempotency-Key header.

1
2
3
4
5
6
7
{
	"type": "about:blank",
	"title": "The request body does not match a previous request that used this Idempotency-Key. Use a new Idempotency-Key for a different request.",
	"status": 409,
	"instance": "/api/v1/commerce/submissions",
	"code": "submission.idempotency_conflict"
}

A 409 means your first submission stands. If you meant to retry that order, look it up rather than resending with a new key. A new key places a second order. Use one only when this really is a different order.

A 409 without a code is something else. It's rare and has nothing to do with your Idempotency-Key, so don't change the key. Contact WHCC support with the message.

The submission.* codes

There are two families. The first is the code on a Problem Details error, meaning your request was rejected. The second, below, appears on a successful response, on an order within your submission that failed at WHCC.

submission.shipping_priority_unsupported

400, submit and calculate, only when you sent both a shipping.priority and an address. The priority is well formed but isn't offered for that destination. invalidParams[] names shipping.priority and its reason lists the priorities of the same shipping type that are available, if there are any. Pick one of those, or call calculate without a priority and choose from options.shipping[]. A type/priority mismatch is a plain 400 without a code, naming both shipping.type and shipping.priority; see the worked example.

submission.not_submittable

400. The submission isn't ready to place, usually because an item isn't valid for order yet, the shipping address is incomplete, or a shipping choice can't be met. A missing or too-short address field gets its own invalidParams[] entry, such as { "name": "shipping.address.zip", "reason": "Required for this shipping type." }. When the cause is an item, such as one whose image failed, the detail reads Some items aren't valid for ordering. Each one is named in invalidParams. and there is one entry per failing item, in request order, named items.N.configuredProductId with the value you sent and a reason such as This configured product isn't valid for ordering: no-photo-selected. Fix the named items and submit again. Send the same body to calculate and check validation.isValidForSubmit, validation.invalidParams[] and each item's isValidForOrder.

unknown configuredProductId, no code

400. An item names a configured product that doesn't exist or belongs to someone else; the two can't be told apart. The invalidParams[] entry is named items.N.configuredProductId with the reason No configured product with this id exists on your account. and the value you sent. A string that isn't shaped like an id at all gets the same name with a type reason. In each case, send the configuredProductId the build returned.

submission.idempotency_conflict

409. The Idempotency-Key was already used with a different body. Your first submission stands; see above.

submission.idempotency_key_required

400, submit only. The Idempotency-Key header is missing. Add it; see the Idempotency-Key header.

submission.idempotency_key_not_applicable

400, calculate only. Calculate doesn't create anything, so it doesn't take an Idempotency-Key. Remove the header.

CURSOR_INVALID

400, GET /v1/commerce/submissions only. The cursor is malformed or was altered. Start again from the first page. An expired cursor is a 410 instead.

CURSOR_FILTER_MISMATCH

400, GET /v1/commerce/submissions only. The cursor is valid but was issued for different filters. Keep the filters the same while you page, or start again from the first page.

unknown_selection_field

400, POST /v1/commerce/configured-products only. A key in selections isn't a field on that product. Use the field names from the product detail.

413, no code

The request body is over 1 MiB. Send fewer items per call. Don't count on it for the fulfillment routes: a body over 1 MiB sent to POST /v1/fulfillment/orders/{id}/shipping/preview is read, not refused with a 413.

submission.pricing.destination_required_for_full_total

Not an error. This is a guidance note on a calculate response with no shipping address, naming the charges that were left out. See Response Bodies.

submission.pricing.total_excludes_tax

Not an error. A guidance note on the submit response, the submission detail, and calculate with a shipping address. It reminds you that totalPrice.value includes tax and totalPrice.total.value doesn't, so bill your customer totalPrice.value. Calculate without an address carries destination_required_for_full_total instead.

When an order fails: submission.failure.*

Each entry in a submission's orders[] that failed carries a failure: { code, message }. The code is one of these. The message is a plain sentence you can log or show. Where a row says to contact WHCC support, reach your WHCC representative or use the Get in Touch form, and include the submission id.

submission.failure.invalid_business_rules

WHCC couldn't accept the order as sent, commonly because a shipping method or option combination isn't available for the destination or product. This is the most common failure. Contact WHCC support to find out which rule applied.

submission.failure.rejected_by_whcc

WHCC's order system rejected the order for another reason. Contact WHCC support for details.

submission.failure.unconfirmed_after_import

WHCC received the order, but we couldn't confirm it went through. Contact WHCC support before resubmitting, so it isn't placed twice.

submission.failure.submission_error

We couldn't send the order to WHCC because of an error on our side. It may need to be resubmitted; contact WHCC support if you're unsure.

submission.failure.print_asset_generation_failed

WHCC accepted the order but couldn't make a print file for one of its items, often because an image is corrupt, unreadable or too small for the product. Check the images and resubmit, or contact WHCC support.

submission.failure.production_error

WHCC accepted the order, but it ran into a problem in production. Contact WHCC support for details.

submission.failure.unknown

The order failed for a reason we couldn't identify. Contact WHCC support.

Other response bodies

The two shapes above cover the commerce, catalog, discovery and fulfillment routes. Some responses use neither, so don't write your error handling to require instance or type: "about:blank" on every non-2xx response.

fulfillment routes

Errors are Problem Details with a detail and, usually, a code, but no instance. This includes the 404 for an order that doesn't exist and the 400 for an unknown query parameter. See fulfillment refusals.

Paths and methods that aren't routes

A path outside the documented route families, such as /v1/nothing-here, /v2/discovery or /, and a method a fulfillment route doesn't support, return a 404 in a different shape: { "statusCode": 404, "error": "Not Found", "message": "Not Found", "type": "", "title": "Not Found", "status": 404, "instance": "…" }. type is an empty string, there is no detail, and there are extra fields. A scope 403 on a route outside the documented route families can use this shape too. Read status, not type or detail.

Malformed URL

A path with a malformed percent-escape, such as %ZZ or %00, can be refused by the load balancer in front of the API with a 400 whose body is HTML, not JSON, even with a valid key. Fix the URL.

Rate limits

The WHCC API has no published rate limit today, so there is no fixed quota to track and no Retry-After header to honor. That can change: as more integrations come on, WHCC may add rate limits or quotas, and we'll announce them here and honor Retry-After when we do. Build as if a 429 could arrive tomorrow. In practice that means not polling for order completion: following actions.poll while a configured product builds is fine and expected, but order status should reach you through an event subscription, not by re-reading the submission or the fulfillment routes on a timer.

Heavy traffic can still be slowed down, sometimes with a response that matches neither body shape above. Treat any 429 or repeated 5xx as a signal to back off. GETs and calculate calls don't create anything, so they're safe to retry. Never retry a submit blindly. A 5xx can arrive after your submission was accepted. Retry only the identical request with the same Idempotency-Key. If the retry returns the original submission ID, use GET /v1/commerce/submissions/{submissionId} to reconcile it. A new key after an unclear failure can place a second order. If your integration needs a steady high request rate, let us know during onboarding.

Back to Top 👆
Get in Touch

Interested in integrating with WHCC? Tell us more about what you’re looking for and we’ll be in touch.