Errors & Conventions
This is the canonical reference for how Firmly returns errors and how your code should respond to them.
Error response shape
Every error response uses the same envelope (the REST envelope — used by all endpoints except the UCP protocol bridge):
{"code": 409,"error": "NotEnoughStockError","description": "The amount of the required item is not available in stock."}
| Field | Meaning |
|---|---|
code |
HTTP status code, repeated in the body |
error |
Stable error name in PascalCase (e.g. CartNotFound, NotEnoughStockError) |
description |
Human-readable message. On validation errors, often includes the failing field path (e.g. "At path: items.0.add_to_cart_ref — Expected an object, but received: undefined") |
The description field is safe to log verbatim. Don’t show it raw to buyers — it can leak technical detail. Map to your own user-facing messaging.
Catalog by category
Authentication
| Name | HTTP | Cause | Recovery |
|---|---|---|---|
MissingAuthHeader |
400 | x-firmly-authorization header missing or empty |
Add the header |
InvalidToken |
400 | Token is not a valid JWT structure (malformed) | Re-bootstrap via POST /api/v1/browser-session |
InvalidJWTToken |
401 | JWT signature couldn’t be verified | Bootstrap a new session, or pass the previous token to Browser Session to renew |
PartnerNotFound |
404 | The appid claim on the JWT doesn’t map to a known partner |
Usually means the token was issued against a different environment — re-bootstrap with the correct App ID |
Routing & resource lookup
| Name | HTTP | Cause | Recovery |
|---|---|---|---|
RouteNotFound |
404 | The path doesn’t match any handler | Check the URL — common cause is a wrong /api/v1/ vs /api/v2/ prefix |
DomainNotFound |
404 | The merchant {domain} parameter isn’t a recognized merchant |
Confirm the domain string from Firmly |
CartNotFound |
404 | No cart exists yet for this device_id + domain |
Create a cart by calling /cart/line-items — cart is implicit |
ProductNotFound |
404 | The product ID / handle doesn’t exist for this merchant | Re-search via /api/v1/discovery/search |
ShipmentNotFound |
404 | Specified shipment_id doesn’t exist on the cart | Refresh the cart via GET /cart to get current shipment IDs |
LineItemNotFound |
404 | Line item ID doesn’t exist | Refresh cart to get current line_item_ids |
AddonNotFound |
404 | Addon offer ID doesn’t exist or isn’t applicable | Re-read available offers from cart.addons.offers[] |
Validation
| Name | HTTP | Cause | Recovery |
|---|---|---|---|
InvalidInputQuery |
400 | A query-string parameter failed validation — a bad value (?flush_cart=1) or an unrecognized key (?utm_source=…), since query schemas are strict. description names the failing parameter |
Fix or drop the query parameter |
InvalidInputBody |
400 | Request body failed schema validation. description includes the failing field path (incl. wrong types, missing required fields, out-of-range quantity, etc.) |
Fix the field and retry |
InvalidInputBody |
422 | Same name, different layer: the body is well-formed but the merchant rejected it — most often an add-on that does not exist or is not offered on this cart. description explains which value was rejected |
Re-read the available add-ons and retry with a valid one |
ErrorInvalidEmail |
400 | Email format is invalid | Validate format before submission |
InvalidShippingInfo |
400 | Shipping address validation failed (missing fields, invalid postal code, etc.) | Read description for which field; fix and retry |
InvalidPromoCode |
409 | Promo code is invalid, expired, or non-stackable | Tell the user; suggest removing or replacing |
CountryNotSupported |
412 | Merchant doesn’t ship to this country | Use a supported country, or surface “not deliverable” to user |
OperationNotSupported |
412 | The operation isn’t enabled for this merchant (e.g. setting a fulfillment type the merchant doesn’t offer). | Surface to the user, or fall back to the merchant’s default. |
NotImplemented |
501 | The merchant’s adapter doesn’t implement this operation — e.g. cart/shipments/get-availability on a V1 adapter. |
Skip the optional call. Shipping methods still come from cart.shipments[].shipping_method_options; get-availability only adds delivery dates / time slots / pickup. See Shipping & Fulfillment. |
PreconditionError |
412 | Required prior step wasn’t completed (e.g. complete-order before shipping is set) | Walk through the missing step |
Inventory & merchant state
| Name | HTTP | Cause | Recovery |
|---|---|---|---|
NotEnoughStockError |
409 | Quantity exceeds current stock | Lower quantity or pick a different variant. Re-fetch the product to see current availability. |
ShipmentNotAvailable |
409 | Selected shipping method is no longer available for this address (e.g. inventory moved, region restriction) | Re-fetch the cart, surface the new options |
ProductNotSupported |
412 | Product can’t be added to cart (type restriction, region, business rule) | Surface to user; check product eligibility |
StoreUnavailable |
503 | Merchant adapter is temporarily unreachable | Retry with exponential backoff (1s → 30s cap). If persistent, surface to user. |
Server
| Name | HTTP | Cause | Recovery |
|---|---|---|---|
UnprocessableEntity |
422 | Request body was valid but couldn’t be processed (downstream merchant call rejected the payload, or a pre-condition like postal code wasn’t met) | Read description; most often resolved by re-fetching the cart |
Unexpected |
500 | Generic server error | Retry once with backoff. If persistent, report to Firmly with the request ID and timestamp |
Place-order specific (on cc.firmly.work)
| Name | HTTP | Cause | Recovery |
|---|---|---|---|
MissingRequiredParameters |
400 | Cart is missing data needed to place the order (e.g. no payment_handle because Set Shipping Info wasn’t called) |
Walk back through the checkout steps; ensure shipping is set |
CheckoutError |
412 | Merchant-side checkout failure (promo re-evaluation rejected, cart total mismatch, etc.) | Re-fetch the cart, surface the discrepancy to the user |
RequiredConsentsNotSigned |
412 | A required && explicit consent from Get Consents wasn’t signed before place-order |
Show the consent, capture acceptance via Set Consents, retry |
CreditCardDeclined |
422 | Card declined by the PSP. Also returned when a PayPal express payment is declined, despite the name — see Complete Order — PayPal. | Don’t silently retry; ask the user for a different payment method. Log issuer reason (don’t show to user). |
CreditCardInsufficientFunds |
422 | Issuer reported insufficient funds | Surface to user; offer a different card |
CreditCardInvalidNumber |
422 | Card number failed PSP validation | Re-collect card details |
CreditCardInvalidSecurityCode |
422 | CVV / verification_value failed PSP validation |
Re-collect CVV |
CreditCardInvalidExpiry |
422 | Expiry combination is invalid or in the past (also: CreditCardInvalidExpiryMonth / …Year / …Date for finer-grained signals) |
Re-collect expiry |
CreditCardInvalidPostalCode |
422 | AVS rejected the postal code in billing_info against the card on file |
Re-collect billing postal code |
RateLimited |
429 | Payment host rate-limited the device for excessive place-order attempts | Back off and retry |
Additional errors by name
Every error the endpoint pages document, beyond the grouped tables above. The last column links to the page carrying the full cause-and-recovery detail for that call.
| Name | HTTP | Cause | Documented on |
|---|---|---|---|
AllProductsNotFound |
404 | No catalog snapshot is available for this merchant — the merchant has no published all-products feed. | get-all-products |
BadRequest |
400 | All of these conditions share the same error (BadRequest) and code (400), so the description is the only way to tell them apart. |
set-consents |
CannotCreateCart |
409 | The merchant system rejected creation of a new cart. | add-line-item |
CannotTransferSession |
409 | The supplied handle could not be turned into a usable merchant session. |
session-transfer |
ClearPromoCodeNotAllowed |
409 | The merchant’s configuration disables API-driven promo-code clearing for this store. | clear-promo-codes |
CreditCardInvalidExpiryDate |
422 | The expiry could not be parsed — the supplied month/year are malformed or do not form a valid date (as opposed to CreditCardInvalidExpiry, which is a well-formed but past expiry). |
complete-order-v2 |
CreditCardInvalidExpiryMonth |
422 | Expiry month was rejected (not 1–12). | complete-order-v2 |
CreditCardInvalidExpiryYear |
422 | Expiry year was rejected. | complete-order-v2 |
PaymentChallengeExpired |
422 | The interactive verification window (3-D Secure or equivalent) closed before the shopper completed it, or the PSP invalidated the challenge. Terminal for that attempt. | Resume After Challenge |
PaymentChallengeRequired |
422 | The merchant’s PSP demanded interactive verification, but the calling App ID has not opted in to the payment-challenge contract — so the order fails closed rather than returning a challenge the client could not present. Also returned on a post-authentication decline, or when no pending challenge exists for the session. | complete-order-v2, Resume After Challenge |
ErrorInvalidAppId |
400 | App-id-auth path: the supplied App ID is not recognized, or exceeds 512 characters. | search |
ErrorMissingAppId |
400 | App-id-auth path: x-firmly-app-id header is missing or empty. |
search |
ErrorStartExpressCheckout |
400 | Firmly could not open the express-checkout session — the provider rejected the request, or the merchant adapter failed to translate it. | Klarna — Start, PayPal — Start |
ErrorSubmitExpressCheckout |
400 | Authorization with the merchant failed — the provider rejected the token, or the merchant adapter could not submit the checkout. | Klarna — Authorize, PayPal — Authorize |
GatewayNotFound |
404 | The express-checkout provider (Google Pay, Klarna or PayPal) is not enabled for this merchant. | Google Pay, Klarna, PayPal |
InvalidAPIToken |
400 | Server-to-server path: the API token is missing, invalid, or has been revoked. | search |
InvalidDeliveryDate |
400 | The supplied selected_date and/or selected_time_slot is not available for this shipment. |
set-shipping-method |
InvalidFulfillmentType |
400 | The supplied fulfillment_type is not in the target shipment’s fulfillment_type_options. |
set-fulfillment-type |
InvalidRequest |
400 | The url query parameter is missing or empty. |
get-product-from-url |
InvalidShipmentType |
400 | The shipment’s fulfillment_type is not one this endpoint can query, or the type is not supported by the merchant. |
get-availability |
InvalidShippingMethod |
400 | The supplied shipping_method_id is not in the shipment’s shipping_method_options. |
set-shipping-method |
InvalidState |
400 | The state_or_province value is not valid for the given country (e.g. "ZZ" for country: "US"). |
set-shipping-info |
LocationNotFound |
404 | The supplied location_id is not a valid pickup location for this merchant. |
set-fulfillment-type |
MissingRequiredCookie |
400 | A cookie the merchant session depends on is absent, so the session transfer could not be completed. | PayPal — Complete Order |
MissingShippingInfo |
412 | No shipping address is set on the cart, so it has no shipments to query. | get-availability |
MissingShippingMethod |
412 | A shipping method has not been selected on the cart yet. | Klarna — Complete Order |
MissingTaxSync |
412 | Cart taxes have not been calculated yet. | Klarna — Complete Order |
MultipleDiscountCodesNotSupported |
409 | Setting shipping triggered a re-evaluation of cart promotions and the merchant rejected the combination of currently-applied discount codes. | set-shipping-info |
NoLineItemError |
412 | The merchant accepted the request but did not return a line item — typically indicates a product was silently rejected by merchant validation. | add-line-item |
PaymentMethodNotAvailable |
409 | The requested wallet payment method is not available for this merchant. | wallet-complete-order |
PostalCodeRequired |
412 | The merchant requires a postal code before line items can be modified. | update-line-item |
ProductDiscontinued |
410 | The product at the supplied url exists but has been discontinued by the merchant. |
get-product-from-url |
ProductUrlNotImplemented |
404 | The merchant’s adapter does not support resolving a product from a URL. | get-product-from-url |
PromoNotAvailable |
409 | The code exists but is not available for the current cart (e.g. minimum-purchase not met, product-restricted, customer-segment-restricted). | add-promo-codes |
ShippingAddressNotSupported |
409 | The supplied address is rejected by the merchant for reasons beyond country/state validation (e.g. APO/FPO, US territories, embargoed regions). | set-shipping-info |
ShippingNotNeeded |
400 | The cart contains only items that do not require shipping (e.g. digital-only). | set-shipping-info |
Unauthorized |
401 | The Server-to-Server credentials were rejected, or no authentication method (Browser Session, App ID, or Server-to-Server) accepted the request. | search |
VariantNotFound |
404 | The url requested a ?variant= that is not among the product’s variants. |
get-product-from-url |
Rate limits
A 429 response means you’ve exceeded your tenant’s rate limit. Honor the Retry-After header.
For the full pattern, recommended retry logic, and how to design high-volume integrations, see Rate limits.
Pagination
Firmly uses two pagination styles depending on the endpoint. Discovery search returns a cursor in next_page when more results are available — pass it on the next request to walk forward. Catalog listing (Get All Products) uses page-number pagination instead: pass page and size, and read page / size / totalPages from the response’s metadata object (with products as a sibling of metadata).
For both patterns, page-size guidance, and cursor-expiry handling, see Pagination.
Idempotency
Pass an Idempotency-Key header (a UUID your code generates) on a mutation request routed through the UCP protocol bridge (see the scope note below). Reuse the same key when retrying the same attempt; generate a fresh key only for a new attempt.
On a UCP-routed mutation, the first time the server sees a given key the mutation runs normally and the response is cached for 24 hours; a retry with the same key returns the cached response, so the underlying operation runs at most once. This applies to the UCP bridge only — core REST endpoints do not deduplicate on the header (see the scope note below).
POST /api/v1/ucp/rest/domain/{domain}/checkout-sessions/{session_id}/completeIdempotency-Key: 7c2a8b1e-4f3d-4a1c-9e6b-2d0a8e7c1b3a
If the same key is reused with a different request body, the UCP bridge rejects the request with the UCP envelope code idempotency_conflict (“idempotency key reused with different parameters”). Either retry with the original body, or generate a new key for the new attempt. (This is UCP-bridge-only — there is no equivalent REST error, since core REST endpoints don’t deduplicate on the header.)
For the full key format and the conflict-detection fingerprint, see Idempotency.
Recovery patterns for common failure modes
Stock changed between discovery and place-order
The classic race: showed the user a product, they confirmed, someone else bought it before you got to place-order.
add-line-item → OKset-shipping → OKplace-order → 409 NotEnoughStockError
Recovery: re-fetch the product, surface the change (“only 1 left — want to proceed with 1 instead of 2?”), update the cart with the new quantity, then retry place-order.
Payment declined
place-order → 422 CreditCardDeclined
Recovery: do not silently retry the same card. Tell the user the card was declined (without showing the issuer’s reason verbatim — that can leak signals about valid card numbers). Offer a different payment method. (A 412 CheckoutError is a distinct, merchant-side checkout failure — promo re-evaluation or a total mismatch — not a card decline.)
Validation error with field path
set-shipping-info → 400 InvalidInputBody"At path: phone — Expected a string with a length between 10 and 13"
The description includes the failing field. Map it to a conversational ask (“what’s the phone number with country code?”) rather than echoing the raw error.
Merchant adapter down
An adapter is Firmly’s connector for a merchant’s commerce platform (see the glossary). Each implements only what its platform supports — hence OperationNotSupported / NotImplemented on some merchants.
add-line-item → 503 StoreUnavailable
Recovery: retry with exponential backoff. If persistent for >30 seconds, surface to user.
get-availability not supported
get-availability → 501 NotImplemented
Some merchants (V1 adapters) don’t implement this endpoint. This doesn’t block checkout: get-availability only returns delivery dates, time slots, and pickup locations — never shipping methods. Shipping methods always come from cart.shipments[].shipping_method_options, populated inline after set-shipping-info. Treat a 501 as “no scheduled-delivery/pickup detail for this merchant” and proceed. See Shipping & Fulfillment.
UCP envelope (UCP protocol layer only)
Consumers of the UCP protocol bridge see a different envelope. Documented here for completeness — most direct REST integrations never see it.
{"messages": [{"type": "error","code": "insufficient_stock","content": "Product 'Example Item' has only 2 in stock","severity": "requires_buyer_input","field": "items[0].quantity"}],"ucp": { "version": "1.0" }}
Severity → agent behavior (UCP only)
severity |
Meaning | What your agent does |
|---|---|---|
recoverable |
Transient or auto-resolvable | Retry with backoff, or auto-correct |
requires_buyer_input |
User must provide or change something | Ask the user, then retry |
requires_buyer_review |
User must confirm something risky | Surface details, wait for explicit confirmation |
unrecoverable |
Flow cannot continue on this path | Tell the user, offer an alternative |
UCP code catalog
| Category | Codes |
|---|---|
| Session | session_not_found, session_expired, session_not_ready, session_already_completed, session_already_canceled |
| Validation | validation_error, invalid_product, invalid_quantity, currency_mismatch, missing_required_field, missing_email, missing_shipping_address, missing_fulfillment_method |
| Inventory | insufficient_stock, product_unavailable |
| Payment | payment_declined, payment_invalid, payment_required |
| Catalog | product_not_found, catalog_unavailable, merchant_not_found, ucp_disabled |
| Service | idempotency_conflict, service_unavailable, internal_error |
Quick reference
| Error name | HTTP | Quick fix |
|---|---|---|
MissingAuthHeader |
400 | Add the auth header |
InvalidToken |
400 | Re-bootstrap session |
InvalidJWTToken |
401 | Re-bootstrap session |
CartNotFound |
404 | Create cart by adding an item |
ProductNotFound |
404 | Re-search via discovery |
NotEnoughStockError |
409 | Lower quantity or pick different variant |
InvalidPromoCode |
409 | Verify code; suggest alternatives |
CountryNotSupported |
412 | Use a supported country |
OperationNotSupported |
412 | Surface to user, or use the merchant default |
NotImplemented |
501 | Skip the optional call; read methods from cart.shipments[].shipping_method_options |
InvalidInputBody |
400 | Read description, fix the failing field |
CreditCardDeclined |
422 | Ask for a different payment method |
StoreUnavailable |
503 | Exponential backoff retry |
Unexpected |
500 | Retry once; if persistent report to support |