Error codes
Every 4xx / 5xx response carries a stable error.code.
Reach for it (not the prose message) when branching in
your handler — codes are versioned with the wire contract; messages are not.
Envelope
// Every Throttle 4xx / 5xx response follows this shape.
{
"error": {
"code": "cart_already_checked_out", // stable contract — use this
"message": "Cart has already been converted to a draft order.",
"details": [ // optional, field-level
{ "field": "cartId", "code": "conflict", "message": "Already checked out" }
]
}
} Common codes
The list below covers the ~20 codes you'll see most often when integrating cart, checkout, payments, and embed flows. The full set is generated from the OpenAPI spec at api.usethrottle.dev/openapi.json .
| error.code | HTTP | Cause |
|---|---|---|
cart_not_found | 404 | Cart id is invalid or belongs to another workspace. |
cart_not_open | 409 | A mutating cart operation targeted a cart no longer in status=open (converted by a completed order, or abandoned). In the cart SDK this surfaces as CartNotOpenError. |
cart_already_checked_out | 409 | POST /carts/{id}/checkout was called twice on the same cart. |
cart_already_converted | 409 | Cart is in status=converted (linked order is paid). Read-only. |
customer_not_found | 404 | Customer id is invalid or belongs to another workspace. |
application_not_found | 404 | Application UUID is invalid or belongs to another workspace. |
application_required | 400 | Endpoint requires X-Throttle-Application-Id header or applicationId in the body. |
application_mismatch | 403 | API key is scoped to a different application than the request target. |
application_key_workspace_route | 403 | API keys cannot access workspace-level routes (Clerk JWT required). |
missing_application_id | 400 | POST /carts called without applicationId in the request body. |
idempotency_key_reused | 409 | Same Idempotency-Key sent with a different request body within 24h. |
invalid_origin | 400 | Origin in allowedOrigins isn't https://<host> or http://localhost:<port>; or it is a wildcard sent to the production environment, or a wildcard over a shared or public suffix. The message says which. |
invalid_quote_token | 400 | pk_ token expired, scope-mismatched, or signed with a stale secret. |
invalid_signature | 401 | Webhook signature failed verification (HMAC mismatch or stale timestamp). |
embed_token_invalid | 401 | Provider embed JWT expired or was tampered with. Re-mint via the proxy endpoint. |
embed_token_failed | 502 | The payment provider refused to mint an embed token (auth/permissions issue upstream). |
permission_denied | 403 | Caller authenticated but lacks the required role / scope. |
workspace_access_denied | 403 | Caller is not a member of the workspace in :workspaceId. |
workspace_not_entitled_for_live | 402 | A production-environment credential was used without active subscription or partner status. |
environment_mismatch | 403 | An API key or dashboard request contradicted its own environment: an API key sent an X-Throttle-Environment-Id for a different environment than the key belongs to, or a storefront session token was minted for a different environment. Use a key for the correct environment, or send the matching header. Fetching another environment’s resource by id answers 404 not_found, not this. |
environment_not_found | 404 | Requested workspace environment does not exist, is archived, or is not accessible to the workspace. |
member_env_forbidden | 403 | Clerk-authenticated member tried to use an environment outside their environment grants. |
environment_slug_taken | 409 | A workspace environment with that slug already exists. |
system_environment_locked | 400 | Production or another system environment cannot be archived. |
validation_error | 400 | Request body failed zod validation. See details[] for field-level errors. |
invalid_interval | 400 | Subscription cadence (create, change-plan, or a checkout recurring intent) named an out-of-vocabulary interval or unit, or a count of 0, negative, fractional, or above the unit max. |
rate_limit_exceeded | 429 | Per-workspace rate limit hit. Retry-After header indicates the cooldown. |
clerk_required | 403 | Endpoint requires a Clerk JWT (API key auth rejected). |
workspace_mismatch | 403 | Clerk JWT workspace does not match the workspace in the URL. |
no_file | 400 | Multipart upload route received a request without a file part. |
mime_not_allowed | 415 | Upload file type is outside the route’s ALLOWED_MIME set. |
logo_too_big | 413 | Logo upload exceeded the per-route byte cap. |
asset_too_big | 413 | Email asset upload exceeded the per-route byte cap. |
file_too_big | 413 | Digital-fulfillment upload exceeded the per-route byte cap. |
fulfillment_not_found | 404 | Digital-upload route’s :id is not a fulfillment in this application. |
fulfillment_not_digital | 404 | Upload route targeted a non-digital fulfillment type. |
missing_token | 400 | Buyer download URL missing ?token= query param. |
invalid_token | 401 | Buyer download token failed JWT verification. |
token_mismatch | 401 | Buyer download token was minted for a different fulfillment. |
download_expired | 410 | Buyer download window has elapsed. |
download_limit_reached | 410 | Buyer download count has reached the configured limit for this delivery. |
invalid_logo_url | 400 | logoUrl on /embed-config is not a valid https URL. |
invalid_primary_color | 400 | primaryColor on /embed-config is not a #rrggbb hex string. |
invalid_storefront_base_url | 400 | storefrontBaseUrl on /embed-config is not an absolute https URL. |
image_url_unresolvable | 400 | A relative line-item imageUrl was sent but the application has no storefrontBaseUrl (or allowedOrigin) to resolve it against. |
Subscription and recurring-checkout codes
Codes returned by the subscription routes and by checkout sessions that carry a plan. See Managing Subscriptions and Starting checkout from an existing cart for the behavior behind them.
| error.code | HTTP | Cause |
|---|---|---|
subscription_terms_undeterminable | 422 | A checkout session was created from an existing cart (hosted /cart/<id>, recovery link, cartId hand-off) whose subscription plan line has no usable plan reference or cadence, and no earlier session on the cart carries the intent. Refused before any charge. |
recurring_customer_required | 422 | A recurring checkout session has no customer email or identifier, and none to inherit from an earlier session on the cart. |
quantity_unchanged | 400 | change-quantity was called with the quantity the subscription already has. Checked before any charge. |
invalid_subscription_state | 400 | An immediate or scheduled change that alters nothing (same plan, cadence and amount) ("Nothing to change"); pause on a subscription that is not active; resume on a cancelled or completed one. |
invalid_state | 400 / 409 / 422 | The subscription (or invoice) is in a state that refuses the action. 400: recording usage on an installment plan. 409: cancelling with a refund a subscription that is already cancelled or completed, or scheduling a change while cancelAtPeriodEnd is true. 422: changing or reporting usage on a cancelled or completed subscription, an immediate change while paused, retry-charge / waive-period when not past due, or a prorated cancel with no unused time left. |
installment_plan_locked | 409 | Amount, cadence, quantity or plan change on an installment plan (totalPayments set), or a manual period renew of a scheduled plan. |
invalid_schedule | 400 | A custom installment schedule is malformed: not 2–60 rows, a gap on row 0, a later row without a valid gap (hour 1–8760, day 1–365, week 1–52, month 1–12), or an amount that is not a positive integer. The message names the row, e.g. "schedule[2].intervalCount: …". |
schedule_first_payment_mismatch | 400 | A line unitPrice (or the POST /subscriptions amount) differs from schedule[0].amount. |
installment_plan_merchant_only | 403 | A buyer session (or the subscription proxy) tried to pause, resume or cancel an installment plan. |
renewal_needs_attention | 409 | An immediate change, retry-charge or waive-period on a subscription holding an unresolved orphan_charge renewalIssue. Scheduled changes and cancellation are still accepted. |
currency_mismatch | 422 | POST /api/v1/subscriptions sent a currency that differs from the application currency. |
payment_failed | 402 | The stored card declined during an immediate plan or seat change. Nothing changed. |
Order line-item edit codes
Codes returned by PATCH /api/v1/orders/:id/line-items, which
adds, updates or removes line items and settles the change on an order with a captured
payment: an increase is charged to the stored card, a decrease refunded. On an order whose
balance is on an outstanding Net-N invoice the invoice takes the change instead — its
amount rises or falls, no card is charged — and the response reports it in data.adjustment.invoice. Every code below
refuses the edit before anything is written, charged or refunded.
| error.code | HTTP | Cause |
|---|---|---|
no_changes | 400 | The body has no add, update or remove entries. |
invalid_line_item | 400 | An update lineItemId or a remove id is not a line item on this order. |
empty_order | 400 | The edit would remove every line item. An order keeps at least one. |
not_found | 404 | No such order in the application and environment of the credential. |
order_not_editable | 409 | The order is cancelled, refunded or voided. |
subscription_order_not_editable | 409 | The order is backed by a subscription. Change the subscription plan instead. |
no_customer | 409 | The edit raises the total of an order with a captured payment, and the order has no customer whose stored card could be charged. |
payment_failed | 402 | Charging the increase to the customer’s stored card failed. Nothing changed. |
tax_origin_missing | 422 | The application calculates tax with a provider (taxMode: "app_based", e.g. Avalara) and has no origin address. Set it under Tax → Setup → Origin. |
tax_address_missing | 422 | The application calculates tax with a provider, the order has no address its tax trigger can use, and the tax fallback policy is strict. Under lenient the edit goes through untaxed with a tax_address_missing warning. |
tax_provider_unavailable | 503 | The tax provider could not answer and the tax fallback policy is strict (the default). Retry once the provider is reachable. Under lenient your native tax rules price the edit and the response carries a provider_fallback warning. |
not_implemented | 501 | The edit raises the total of an order with a captured payment on a server that has no stored-card charging configured. |
When the application calculates tax with a provider, the edit re-quotes tax with it. Under the
lenient fallback policy a failure the strict policy refuses goes through instead, and the 200
response carries data.warnings: [{ code, message }]: provider_fallback (the provider could not answer, so your
native tax rules priced the edit), tax_address_missing (no
address to tax from, so no tax was charged), or tax_zero_from_provider (the provider returned no tax for the
address). The field is absent when there is nothing to report. A refund that fails after the
items changed does not error either: it comes back in data.adjustment.refundError, and the refund can be retried
from the order. Likewise, a Net-N invoice the edit leaves owing nothing is voided, and a void
that fails comes back in data.adjustment.invoice.voidError.
Validation errors
When a zod body validator fails, the envelope carries code: "validation_error" and a per-field details[] array. Strict-input endpoints return only
the canonical camelCase contract. Field-level details identify unrecognized keys and may
include a suggestion string when the server can
identify the intended field:
{
"error": {
"code": "validation_error",
"message": "Request body validation failed",
"details": [
{
"path": "",
"message": "Unsupported field(s): unexpectedField",
"code": "unrecognized_keys",
"received": ["unexpectedField"]
}
]
}
} error.details[].path. The validator names the
canonical key it expected.
SDK errors
Every Throttle SDK throws the same error class — ThrottleError from
@usethrottle/errors
, re-exported from each SDK. It carries code, statusCode, message, and optional details. The historical per-SDK names still work and
are subclasses, so instanceof ThrottleApiError (cart)
and instanceof ThrottleCheckoutError (checkout) keep
working — and a single instanceof ThrottleError
catches errors from all of them.
import { ThrottleError } from '@usethrottle/cart'; // same class in every SDK
try {
await cart.items.add(cartId, item);
await checkout.completeSession(sessionId, payment);
} catch (e) {
if (e instanceof ThrottleError) {
console.error(e.statusCode, e.code, e.message);
} else {
throw e;
}
}
The cart SDK additionally throws typed subclasses for the two lifecycle
failures worth branching on, so you don't have to sniff error.code strings. Both extend ThrottleApiError (and therefore ThrottleError): CartNotOpenError (409 cart_not_open) and CartNotFoundError (404).
import { CartNotOpenError } from '@usethrottle/cart';
try {
await cart.shipping.select(cartId, method);
} catch (e) {
if (e instanceof CartNotOpenError) {
// The cart was converted by a completed order (terminal) or abandoned.
// Rebuild a fresh cart from your last-known line items and retry.
cartId = await rebuildCart();
} else {
throw e;
}
} HTTP code conventions
- 400 — request was malformed (validation, missing required field, invalid value).
- 401 — authentication missing or invalid (key, signature, JWT).
- 402 — request requires a paid subscription that isn't active (production entitlement).
- 403 — authenticated but not authorized for this workspace / application / role.
- 404 — resource doesn't exist (or exists in a different workspace).
- 409 — request conflicts with current state (cart already checked out, Idempotency-Key reused, slug taken).
- 410 — resource was permanently removed (revoked invitation, abandoned cart).
- 422 — semantic validation failed (e.g. payment amount exceeds order total).
- 429 — rate limit exceeded.
- 5xx — Throttle problem. Idempotency-Key replays do NOT cache 5xx, so safe to retry with the same key.