---
title: "Errors"
description: "The error envelopes the API actually returns, per status code"
---

> API Reference Index
> Fetch the complete API index at: https://docs.firearmcart.com/api/llms.txt
> Use this file to discover all available pages before exploring further.

# Errors

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.

```json
{
"message": "Unauthenticated."
}
```

Validation failures add an `errors` object:

```json
{
"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.

```json
{
"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.

```json
{
"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/charge` and `POST /v1/orders/{order_id}/upsell` return `success: true` when they succeed, but the subscription endpoints return a bare `{"data": …}` on success and only add `success: false` when they fail. Branch on the HTTP status first, then read `error` if 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:

```json
{
"message": "Unauthenticated."
}
```

**Token authenticated but did not resolve to a store** — rejected at the API access gate:

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

```json
{
"message": "Invalid ability provided."
}
```

The body does not name the missing ability. Cross-reference the endpoint against the [abilities table](/api/authentication#abilities), then mint a replacement token — abilities cannot be added to an existing token.

**Store's plan does not include API access:**

```json
{
"error": "forbidden",
"message": "API access is not enabled for this team."
}
```

**Store's subscription is suspended or past its cancellation date:**

```json
{
"error": "subscription_expired",
"message": "Your subscription has expired. Please renew to continue using the API."
}
```

Both `403`s from the gate are billing problems, not code problems. See [Authentication](/api/authentication#api-access-gate).

### `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:

```json
{
"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:

```json
{
"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`

```json
{
"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:

```json
{
"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:

```json
{
"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_declined` means 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` |

```json
{
"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`:

```json
{
"success": false,
"error": "customer_not_found",
"message": "Customer not found."
}
```

### `429 Too Many Requests`

```json
{
"message": "Too Many Attempts."
}
```

The retry delay is in the `Retry-After` header, never in the body. See [Rate limits](/api/rate-limits#the-429-response).

### `500 Internal Server Error`

Two bodies.

**Unhandled exception** — framework envelope, with a fixed message:

```json
{
"message": "Server Error"
}
```

**Payment or upsell processing failed** — operation envelope. The underlying exception is reported to error monitoring; the body deliberately reveals nothing:

```json
{
"success": false,
"error": "processing_error",
"message": "An error occurred while processing the payment."
}
```

> **Warning:** A `500` from `POST /v1/payments/charge` is **not** proof that no charge occurred. The endpoint has no idempotency key, so blind retries can double-charge. Reconcile with `GET /v1/orders?customer_id=…` before retrying. `POST /v1/orders/{order_id}/upsell` requires an `idempotency_key` and is safe to retry with the same key.

---

## Handling errors

```bash
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 ;;
esac
```

A 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](/api/authentication) — what `401` and `403` mean, and how to fix each
- [Rate limits](/api/rate-limits) — the `429` contract
- [Conventions](/api/conventions) — the id and money rules behind most `404`s and validation failures

Source: https://docs.firearmcart.com/api/errors/index.mdx
