The API has no custom exception handler. Failures raised by the framework come back in its default shapes; failures raised by an endpoint come back in that endpoint’s own shape. The result is three different error envelopes on one API. Handle all three.
Send Accept: application/json
Successful responses are JSON regardless of headers. Error responses are not.
The framework only serialises an exception to JSON when the request asked for JSON. Without Accept: application/json:
| Failure | With the header | Without the header |
|---|---|---|
| Unauthenticated | 401 JSON |
302 redirect to https://app.firearmcart.com/login |
| Validation failure | 422 JSON with errors |
302 redirect, errors flashed to a session you cannot read |
| Everything else | JSON | HTML error page |
Set it globally in your HTTP client and never think about it again. Every example in this reference includes it.
The three envelopes
1. Framework envelope — message only
Produced by the framework for authentication, authorization, routing, throttling and unhandled exceptions.
{
"message": "Unauthenticated."
}Validation failures add an errors object:
{
"message": "The email field is required.",
"errors": {
"email": ["The email field is required."]
}
}2. Gate and lookup envelope — error + message
Produced by the API access middleware and by the “not found” branch of most controllers. error is a stable machine-readable slug; message is human-readable and may change.
{
"error": "not_found",
"message": "Order not found."
}3. Operation envelope — success + error + message
Produced by the write endpoints — payments, upsells, and subscription updates. Same two keys as above, plus success: false. Card declines add a details object.
{
"success": false,
"error": "payment_declined",
"message": "Insufficient funds.",
"details": {
"response_code": "51",
"response_message": "Insufficient funds."
}
}Note: Do not branch on the presence of
success. It is inconsistent even within this envelope:POST /v1/payments/chargeandPOST /v1/orders/{order_id}/upsellreturnsuccess: truewhen they succeed, but the subscription endpoints return a bare{"data": …}on success and only addsuccess: falsewhen they fail. Branch on the HTTP status first, then readerrorif it is present.
There is no validation_error envelope and no rate_limit_exceeded envelope. Older FirearmCart material documents both; neither exists.
By status code
401 Unauthorized
Two distinct bodies, from two different layers.
Missing, malformed, revoked or unknown token — rejected at the auth guard:
{
"message": "Unauthenticated."
}Token authenticated but did not resolve to a store — rejected at the API access gate:
{
"error": "unauthorized",
"message": "Invalid API token."
}Neither is retryable. Check the token value, then check that it has not been deleted in the dashboard.
403 Forbidden
Three bodies.
Token is missing the ability the route requires:
{
"message": "Invalid ability provided."
}The body does not name the missing ability. Cross-reference the endpoint against the abilities table, then mint a replacement token — abilities cannot be added to an existing token.
Store’s plan does not include API access:
{
"error": "forbidden",
"message": "API access is not enabled for this team."
}Store’s subscription is suspended or past its cancellation date:
{
"error": "subscription_expired",
"message": "Your subscription has expired. Please renew to continue using the API."
}Both 403s from the gate are billing problems, not code problems. See Authentication.
404 Not Found
Resource does not exist, or belongs to another store. Every lookup is scoped to the token’s store, so a valid id from a different store is indistinguishable from an id that does not exist:
{
"error": "not_found",
"message": "Order not found."
}The message names the resource: Customer not found., Order not found., Product not found., Fulfillment not found., Subscription not found.
URL does not match any route — framework envelope, and the path is echoed back:
{
"message": "The route v1/order/1042 could not be found."
}If you see the second shape, you have a typo in the path, not a missing record.
405 Method Not Allowed
{
"message": "The GET method is not supported for this route. Supported methods: POST."
}409 Conflict
Only from POST /v1/orders/{order_id}/upsell, when another add-on for the same order is mid-flight:
{
"success": false,
"error": "in_progress",
"message": "Another add-on for this order is currently processing. Please retry."
}This one is retryable. Back off briefly and retry with the same idempotency_key.
422 Unprocessable Entity
Two very different meanings share this code.
Request validation failed — framework envelope. errors maps each rejected field to an array of messages, using dot notation for nested and array fields:
{
"message": "The items.0.quantity field is required.",
"errors": {
"items.0.quantity": ["Each item must have a quantity."],
"payment_type": ["The selected payment type is invalid."]
}
}Business rule rejected — operation envelope, with a slug in error:
error |
Endpoint | Meaning |
|---|---|---|
payment_declined |
charge, upsell | Gateway declined the card. Carries details.response_code. |
order_not_paid |
upsell | Add-ons require an order that has actually been paid. |
upsell_invalid |
upsell | Product, variant, card or merchant account could not be resolved. |
address_not_found |
subscription update | shipping_address_id does not belong to this subscription’s customer. |
payment_method_not_found |
subscription update | payment_method_id does not belong to this subscription’s customer. |
Important: A
422 payment_declinedmeans a failed transaction was recorded against the order. The order exists and is in a failed state — do not treat the decline as “nothing happened”.
400 Bad Request
Only from POST /v1/payments/charge, for order-construction problems found before the card is touched. No money moved and no order was created.
error |
Meaning |
|---|---|
payment_method_not_found |
No usable card on file, or the supplied token did not resolve |
product_not_found |
A product_id in items does not exist in this store |
variant_not_found |
A variant_id does not belong to the given product |
subscription_not_allowed |
A serialized firearm cannot be sold on a recurring cadence |
subscription_not_available |
The product is not subscription-eligible |
cadence_not_offered |
The product does not offer the requested cadence |
subscription_required |
The product is subscription-only; supply a cadence |
{
"success": false,
"error": "product_not_found",
"message": "Product with ID 9999 not found."
}404 from POST /v1/payments/charge
Charge also uses 404 for one case — the customer could not be matched by customer_id or email:
{
"success": false,
"error": "customer_not_found",
"message": "Customer not found."
}429 Too Many Requests
{
"message": "Too Many Attempts."
}The retry delay is in the Retry-After header, never in the body. See Rate limits.
500 Internal Server Error
Two bodies.
Unhandled exception — framework envelope, with a fixed message:
{
"message": "Server Error"
}Payment or upsell processing failed — operation envelope. The underlying exception is reported to error monitoring; the body deliberately reveals nothing:
{
"success": false,
"error": "processing_error",
"message": "An error occurred while processing the payment."
}Warning: A
500fromPOST /v1/payments/chargeis not proof that no charge occurred. The endpoint has no idempotency key, so blind retries can double-charge. Reconcile withGET /v1/orders?customer_id=…before retrying.POST /v1/orders/{order_id}/upsellrequires anidempotency_keyand is safe to retry with the same key.
Handling errors
status=$(curl -s -o /tmp/body -w '%{http_code}' \
"https://api.firearmcart.com/v1/orders/1042" \
-H "Authorization: Bearer $FIREARMCART_TOKEN" \
-H "Accept: application/json")
case "$status" in
2*) jq '.data' /tmp/body ;;
401) echo "Token is missing, wrong, or revoked." ;;
403) jq -r '.error // "missing_ability"' /tmp/body ;;
404) echo "Not in this store." ;;
409|429|5*) echo "Retryable — back off." ;;
4*) jq -r '.error // .message' /tmp/body ;;
esacA reasonable retry policy:
| Status | Retry? |
|---|---|
401, 403, 404, 405 |
Never — fix the request or the token |
422 |
Never for validation; never for a decline |
409 |
Yes, after a short pause, same idempotency key |
429 |
Yes, after Retry-After seconds |
500, 502, 503, 504 |
Yes with backoff — except on POST /v1/payments/charge, which is not idempotent |
Related documentation
- Authentication — what
401and403mean, and how to fix each - Rate limits — the
429contract - Conventions — the id and money rules behind most
404s and validation failures

