---
title: "Orders"
description: "Read a store's orders and charge post-purchase add-ons against an order that has already been paid."
---

> 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.

# Orders

An order is the record of a completed checkout: who bought, what they bought, what was
charged, and how it shipped. Two of the three endpoints here are read-only; the third,
the upsell endpoint, is the one place in the API where an order is mutated and a card is
charged.

Reach for orders when you are reconciling revenue, driving an order-status page, or
syncing into an ERP. If all you need is tracking data, the
[fulfillments endpoints](/api/resources/fulfillments) are cheaper and more direct.

## Identifiers

Orders are addressed by `order_id`: a per-store
sequential number that matches what the dashboard shows. It is not a database primary
key, and it is only unique within your team. The same convention holds for
`customer_id`, `product_id` and `variant_id`.

Three fields use UUIDs instead: `fulfillment_id`, `transaction_id` and
`reference_transaction_id`. Treat them as opaque strings — they are stable, globally
unique, and deliberately not sequential. No field on an order is a database primary key.

## Money

Orders emit money as **floating-point dollars** — `"total": 644.46` means $644.46. This
covers `subtotal`, `tax`, `shipping`, `shipping_insurance`, `surcharge`, `discount`, `total`, every
`items[].price` and `items[].subtotal`, and every `transactions[].amount`.

The upsell endpoint is the exception, and it is inconsistent in both directions: its
request body takes `items[].price` and `tax` in **integer cents**, and the
`transaction.amount` it returns is also in integer cents, while the `order` object in
that same response is still in dollars. Parse the two halves separately.

Values are JSON numbers, so a whole-dollar amount serializes without a fractional part
(`"discount": 25`, not `25.00`). Do not rely on the decimal places to detect the unit.

## Order status

The `status` on every order is not the stored column. It is re-derived from the order's
transaction ledger on each read, because reversals are recorded as separate rows rather
than by mutating the original payment. The derived value is one of:

`pending`, `awaiting_payment`, `failed`, `completed`, `refunded`,
`partially_refunded`, `void`, `partially_void`, `chargeback`, `partially_chargeback`

Externally settled marketplace orders — currently GunBroker imports — carry no local
payment ledger, so they report their stored status instead. That is the only way values
like `paid` or `shipped` appear.

This matters when filtering: `?status=` is matched against the **stored** column, so a
filtered list can contain orders whose returned `status` is something else. Filter to
narrow the result set, then branch on the value you actually get back.

## What is included

List results embed items, transactions, the saved card summary and both addresses.
They do **not** include `fulfillments` — that key is omitted entirely rather than
returned empty. Only the single-order endpoint loads fulfillments and their shipments.

Addresses carry street, city, state and ZIP only. There is no recipient name, company,
country or phone behind the address object, so a label cannot be reconstructed from an
order response alone.

## Errors

Orders use the platform-wide error envelope of `error` plus `message`, with one
inconsistency worth coding around: a missing order is `not_found` on
`GET /v1/orders/{order_id}` but `order_not_found` on the upsell endpoint. Match on the
HTTP status, not the string.

Malformed upsell bodies return the stock validation shape (`message` plus a keyed
`errors` object) rather than the `error`/`message` envelope, and rate limiting returns a
bare `{"message": "Too Many Attempts."}`. See [Errors](/api/errors) for the full
picture and [Authentication](/api/authentication) for how abilities are granted.

## List orders

`GET /v1/orders`

Returns a paginated list of the authenticated team's orders, newest first. Every order arrives with its line items, transaction ledger, saved card summary and both addresses already embedded, so a single page of results is usually enough to reconcile without follow-up calls.

- **Operation id:** `orders.list`
- **Required ability:** `orders:read`
- **Rate limit:** 60 requests/minute per team

#### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `customer_id` | integer | no | Only orders belonging to this customer. This is the `customer_id` returned by the Customers endpoints, not a database id. Example: `1001` |
| `status` | string | no | Exact match against the order's stored status column. Accepted values: pending, awaiting_payment, completed, paid, shipped, failed, void, partially_void, refunded, partially_refunded, chargeback, partially_chargeback. This filters the stored column, while the status in the response is re-derived from the transaction ledger, so the two can disagree. Example: `completed` |
| `created_since` | string | no | Only orders created at or after this timestamp. The value is passed straight to the database, so send an ISO 8601 timestamp or a plain yyyy-mm-dd date in UTC. There is no validation layer here: an unparseable value produces a 500, not a 422. Example: `2026-08-01T00:00:00Z` |
| `page` | integer | no | Page number, starting at 1. Anything that is not a positive integer falls back to page 1. Example: `2` |
| `per_page` | integer | no | Results per page. Defaults to 25 and is capped at 100. There is no lower bound, and out-of-range values are not rejected: see the caveats below. Example: `50` |

#### Request

```bash
curl -G https://api.firearmcart.com/v1/orders \
  -H "Authorization: Bearer $FIREARMCART_TOKEN" \
  -H "Accept: application/json" \
  -d status=completed \
  -d created_since=2026-08-01T00:00:00Z \
  -d per_page=25
```

#### Responses

##### 200 — A page of orders, newest first.

```json
{
  "data": [
    {
      "order_id": 5001,
      "customer_id": 1001,
      "status": "completed",
      "subtotal": 599.97,
      "tax": 49.5,
      "shipping": 19.99,
      "shipping_insurance": 0,
    "surcharge": 0,
      "surcharge": 0,
      "discount": 25,
      "total": 644.46,
      "items": [
        {
          "product_id": 501,
          "variant_id": 1201,
          "name": "Glock 19 Gen 5 9mm Pistol",
          "variant_name": "Finish: Black / Capacity: 15rd",
          "sku": "GLK-PA195S201-BLK",
          "quantity": 1,
          "price": 549.99,
          "subtotal": 549.99,
          "is_cancelled": false,
          "is_recurring": false,
          "properties": null
        },
        {
          "product_id": 733,
          "variant_id": null,
          "name": "Federal American Eagle 9mm 115gr, 50rd",
          "variant_name": null,
          "sku": "FED-AE9DP",
          "quantity": 2,
          "price": 24.99,
          "subtotal": 49.98,
          "is_cancelled": false,
          "is_recurring": false,
          "properties": null
        }
      ],
      "transactions": [
        {
          "transaction_id": "a7c3e9f1-52b8-4d6a-9e04-8b1f6c2d3a90",
          "type": "payment",
          "status": "completed",
          "amount": 644.46,
          "gateway_transaction_id": "11ef9c2a-4d31-4f27-9a10-6c1f0b8e2d55",
          "reference_transaction_id": null,
          "created_at": "2026-08-04T15:22:10+00:00"
        }
      ],
      "payment_method": {
        "brand": "visa",
        "last_four": "4242"
      },
      "shipping_address": {
        "address_id": "6e1d9f27-3c4b-4a85-b1e0-5d8c2f7a94b3",
        "type": "shipping",
        "address_line1": "1200 Market Street",
        "address_line2": "Suite 400",
        "city": "Chattanooga",
        "state": "TN",
        "zip_code": "37402",
        "is_billing": false,
        "is_shipping": true,
        "is_default_billing": false,
        "is_default_shipping": true
      },
      "billing_address": {
        "address_id": "a3f82c50-1d6e-47b9-8e42-9c0d7b3e5f18",
        "type": "billing",
        "address_line1": "88 Riverfront Parkway",
        "address_line2": null,
        "city": "Chattanooga",
        "state": "TN",
        "zip_code": "37402",
        "is_billing": true,
        "is_shipping": false,
        "is_default_billing": true,
        "is_default_shipping": false
      },
      "created_at": "2026-08-04T15:22:08+00:00",
      "updated_at": "2026-08-05T09:14:33+00:00"
    }
  ],
  "links": {
    "first": "https://api.firearmcart.com/v1/orders?page=1",
    "last": "https://api.firearmcart.com/v1/orders?page=2",
    "prev": null,
    "next": "https://api.firearmcart.com/v1/orders?page=2"
  },
  "meta": {
    "current_page": 1,
    "from": 1,
    "last_page": 2,
    "links": [
      { "url": null, "label": "&laquo; Previous", "page": null, "active": false },
      { "url": "https://api.firearmcart.com/v1/orders?page=1", "label": "1", "page": 1, "active": true },
      { "url": "https://api.firearmcart.com/v1/orders?page=2", "label": "2", "page": 2, "active": false },
      { "url": "https://api.firearmcart.com/v1/orders?page=2", "label": "Next &raquo;", "page": 2, "active": false }
    ],
    "path": "https://api.firearmcart.com/v1/orders",
    "per_page": 25,
    "to": 25,
    "total": 40
  }
}
```

##### 403 — The token is missing the orders:read ability. A team without API access, or with a lapsed subscription, also 403s here, with an error of forbidden or subscription_expired.

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

##### 429 — More than 60 requests in the current minute for this team. Read Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset from the response headers.

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

#### Caveats

- The fulfillments key is absent from list results entirely, because this endpoint does not eager-load that relation. Use the single-order endpoint, or the Fulfillments endpoints, when you need shipment data.
- Pagination links carry only the page parameter. Your customer_id, status and created_since filters are dropped from links.next, so re-send them yourself when walking pages.
- per_page is capped at 100 but has no floor and is never validated. A value of 0, or any non-numeric string, silently falls back to an internal default of 15 rather than the documented 25; a negative value removes the LIMIT altogether and returns every matching order in a single response.
- status is recomputed from the order's transaction ledger on every read, so it can differ from the stored value you filtered on. The derived value is one of pending, awaiting_payment, failed, completed, refunded, partially_refunded, void, partially_void, chargeback, partially_chargeback. Externally settled marketplace orders (source gunbroker) are the exception and report their stored status instead, which is how paid and shipped can appear.
- shipping is the sum of the order's standard shipping and its FFL transfer shipping. The two are not exposed separately.

## Retrieve an order

`GET /v1/orders/{order_id}`

Returns a single order by its `order_id`, scoped to the authenticated team. This is the only orders endpoint that includes the fulfillments array and its nested shipments, so it is what you poll for tracking numbers.

- **Operation id:** `orders.get`
- **Required ability:** `orders:read`
- **Rate limit:** 60 requests/minute per team

#### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `order_id` | integer | yes | The order's `order_id`: the per-store sequential number shown in the dashboard. Not the database primary key. Example: `5001` |

#### Request

```bash
curl https://api.firearmcart.com/v1/orders/5001 \
  -H "Authorization: Bearer $FIREARMCART_TOKEN" \
  -H "Accept: application/json"
```

#### Responses

##### 200 — The order, with fulfillments and their shipments included.

```json
{
  "data": {
    "order_id": 5001,
    "customer_id": 1001,
    "status": "completed",
    "subtotal": 599.97,
    "tax": 49.5,
    "shipping": 19.99,
    "shipping_insurance": 0,
    "surcharge": 0,
    "discount": 25,
    "total": 644.46,
    "items": [
      {
        "product_id": 501,
        "variant_id": 1201,
        "name": "Glock 19 Gen 5 9mm Pistol",
        "variant_name": "Finish: Black / Capacity: 15rd",
        "sku": "GLK-PA195S201-BLK",
        "quantity": 1,
        "price": 549.99,
        "subtotal": 549.99,
        "is_cancelled": false,
        "is_recurring": false,
        "properties": null
      },
      {
        "product_id": 733,
        "variant_id": null,
        "name": "Federal American Eagle 9mm 115gr, 50rd",
        "variant_name": null,
        "sku": "FED-AE9DP",
        "quantity": 2,
        "price": 24.99,
        "subtotal": 49.98,
        "is_cancelled": false,
        "is_recurring": false,
        "properties": null
      }
    ],
    "transactions": [
      {
        "transaction_id": "a7c3e9f1-52b8-4d6a-9e04-8b1f6c2d3a90",
        "type": "payment",
        "status": "completed",
        "amount": 644.46,
        "gateway_transaction_id": "11ef9c2a-4d31-4f27-9a10-6c1f0b8e2d55",
        "reference_transaction_id": null,
        "created_at": "2026-08-04T15:22:10+00:00"
      }
    ],
    "payment_method": {
      "brand": "visa",
      "last_four": "4242"
    },
    "shipping_address": {
      "address_id": "6e1d9f27-3c4b-4a85-b1e0-5d8c2f7a94b3",
      "type": "shipping",
      "address_line1": "1200 Market Street",
      "address_line2": "Suite 400",
      "city": "Chattanooga",
      "state": "TN",
      "zip_code": "37402",
      "is_billing": false,
      "is_shipping": true,
      "is_default_billing": false,
      "is_default_shipping": true
    },
    "billing_address": {
      "address_id": "a3f82c50-1d6e-47b9-8e42-9c0d7b3e5f18",
      "type": "billing",
      "address_line1": "88 Riverfront Parkway",
      "address_line2": null,
      "city": "Chattanooga",
      "state": "TN",
      "zip_code": "37402",
      "is_billing": true,
      "is_shipping": false,
      "is_default_billing": true,
      "is_default_shipping": false
    },
    "fulfillments": [
      {
        "fulfillment_id": "9c2f1a44-73d1-4c8e-9d3e-2f5b8a61c7aa",
        "order_id": 5001,
        "status": "shipped",
        "fulfillment_type": "rsr",
        "service": null,
        "shipments": [
          {
            "tracking_number": "1Z999AA10123456784",
            "carrier": "ups",
            "tracking_url": "https://www.ups.com/track?tracknum=1Z999AA10123456784",
            "shipped_at": "2026-08-05T09:14:31+00:00"
          }
        ],
        "timeline": [],
        "notes": null,
        "processed_at": "2026-08-04T18:03:00+00:00",
        "shipped_at": "2026-08-05T09:14:31+00:00",
        "delivered_at": null,
        "delivery_failed": false,
        "delivery_reason": null,
        "created_at": "2026-08-04T15:22:12+00:00",
        "updated_at": "2026-08-05T09:14:33+00:00"
      }
    ],
    "created_at": "2026-08-04T15:22:08+00:00",
    "updated_at": "2026-08-05T09:14:33+00:00"
  }
}
```

##### 404 — No order with that `order_id` belongs to the authenticated team. Another team's order is indistinguishable from one that does not exist.

```json
{
  "error": "not_found",
  "message": "Order not found."
}
```

#### Caveats

- The path segment must be numeric. No route constraint enforces it, so a value such as /v1/orders/abc fails type coercion in the controller and returns a 500 rather than a 404.
- fulfillment_id, transaction_id and reference_transaction_id are UUIDs. order_id, customer_id, product_id and variant_id are store-scoped numbers. No field on this resource is a database primary key.
- Addresses expose only street, city, state and ZIP. There is no recipient name, company, country or phone column behind the address resource, so those never appear.
- An address's type is derived from its role flags rather than stored: an address that is billing-only reports billing and anything else reports shipping. Read is_billing and is_shipping when one address serves both roles.
- A nested fulfillment's fulfillment_type is the supplier that ships it — the distributor plugin's slug (rsr, chattanooga, lipseys, 2aw, eprolo, mge, printify, shipstation) or internal for stock you ship yourself. `mge` is always a warehouse-restock leg (MGE ships to the store, not the customer). It is not a destination flag, so there is no ffl value; whether the shipment routes through a dealer is not exposed on this resource. The sibling service field is a legacy column no production code path writes, so expect null.
- variant_name is built from the variant's option values (for example "Finish: Black / Capacity: 15rd"), not from the variant's name column. A variant with no options yields an empty string rather than null.
- An order with no saved card reports payment_method as null, but a missing address does NOT report null. shipping_address and billing_address are wrapped with the single-argument form of whenLoaded, which hands the null relation straight to the address resource, and that resource has no null guard — so an order whose address row is missing or soft-deleted returns a full address object with every field null (and is_billing/is_shipping false) rather than the null you would expect. Test address_line1 for null, not the object itself.

## Add a post-purchase upsell

`POST /v1/orders/{order_id}/upsell`

Appends one or more add-on line items to an order that has already been paid, and charges the card on file for just the add-on amount. The charge is recorded as a second transaction on the same order, and the order's subtotal, tax and total are incremented rather than recomputed, so an existing discount or insurance line survives untouched.

- **Operation id:** `orders.upsell`
- **Required ability:** `orders:upsell`
- **Rate limit:** 30 requests/minute per team

#### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `order_id` | integer | yes | The `order_id` of the order to extend. It must already carry a completed payment, or be an externally settled marketplace order. Example: `5001` |

#### Body parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `items` | array<object> | yes | The add-on line items. At least one entry is required. |
| `items[].product_id` | integer | yes | The product's `product_id`. It must belong to the same store as the order, or the request fails with upsell_invalid. Example: `733` |
| `items[].variant_id` | integer | no | The variant's `variant_id`, which is scoped to the product above. Omit it or send null for a product without variants. Example: `1201` |
| `items[].quantity` | integer | yes | Units to add. Minimum 1. Example: `1` |
| `items[].price` | integer | no | Unit price override in CENTS. Minimum 0. Omit it to charge the variant's price, or the product's price when no variant is given. Example: `2499` |
| `tax` | integer | no | Tax for the add-on, in CENTS. Minimum 0. Nothing is calculated for you: omit it and the add-on is charged with zero tax. Add-ons never carry shipping. Example: `206` |
| `idempotency_key` | string | yes | Your own unique key for this add-on, up to 255 characters. Replaying it returns the original result instead of charging again. Read the caveats before choosing a key format. Example: `addon_5001_1a2b3c4d` |

#### Request

```bash
curl -X POST https://api.firearmcart.com/v1/orders/5001/upsell \
  -H "Authorization: Bearer $FIREARMCART_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "items": [
      { "product_id": 733, "quantity": 1, "price": 2499 }
    ],
    "tax": 206,
    "idempotency_key": "addon_5001_1a2b3c4d"
  }'
```

#### Responses

##### 200 — The add-on was charged and the line items were added. replayed is true when a previously completed charge with the same idempotency_key was returned instead of a new one.

```json
{
  "success": true,
  "replayed": false,
  "order": {
    "order_id": 5001,
    "customer_id": 1001,
    "status": "completed",
    "subtotal": 574.98,
    "tax": 47.43,
    "shipping": 19.99,
    "shipping_insurance": 0,
    "surcharge": 0,
    "discount": 0,
    "total": 642.4,
    "items": [
      {
        "product_id": 501,
        "variant_id": 1201,
        "name": "Glock 19 Gen 5 9mm Pistol",
        "variant_name": "Finish: Black / Capacity: 15rd",
        "sku": "GLK-PA195S201-BLK",
        "quantity": 1,
        "price": 549.99,
        "subtotal": 549.99,
        "is_cancelled": false,
        "is_recurring": false,
        "properties": null
      },
      {
        "product_id": 733,
        "variant_id": null,
        "name": "Federal American Eagle 9mm 115gr, 50rd",
        "variant_name": null,
        "sku": "FED-AE9DP",
        "quantity": 1,
        "price": 24.99,
        "subtotal": 24.99,
        "is_cancelled": false,
        "is_recurring": false,
        "properties": {
          "_upsell": true
        }
      }
    ],
    "transactions": [
      {
        "transaction_id": "a7c3e9f1-52b8-4d6a-9e04-8b1f6c2d3a90",
        "type": "payment",
        "status": "completed",
        "amount": 615.35,
        "gateway_transaction_id": "11ef9c2a-4d31-4f27-9a10-6c1f0b8e2d55",
        "reference_transaction_id": null,
        "created_at": "2026-08-04T15:22:10+00:00"
      },
      {
        "transaction_id": "c1d8f4a2-7e39-4b06-a5c7-9f2e0d6b8a41",
        "type": "payment",
        "status": "completed",
        "amount": 27.05,
        "gateway_transaction_id": "11ef9c2a-77b0-4a19-8c31-2f7e5d9a1b04",
        "reference_transaction_id": null,
        "created_at": "2026-08-04T15:41:02+00:00"
      }
    ],
    "payment_method": {
      "brand": "visa",
      "last_four": "4242"
    },
    "created_at": "2026-08-04T15:22:08+00:00",
    "updated_at": "2026-08-04T15:41:02+00:00"
  },
  "transaction": {
    "transaction_id": "c1d8f4a2-7e39-4b06-a5c7-9f2e0d6b8a41",
    "gateway_transaction_id": "11ef9c2a-77b0-4a19-8c31-2f7e5d9a1b04",
    "amount": 2705,
    "status": "completed"
  }
}
```

##### 422 — Four different failures share this status, told apart by the error field: payment_declined (shown here, with a details object carrying the gateway codes), order_not_paid when the order has no completed payment, upsell_invalid when a product or variant is not found or the order has no card or merchant account to charge, and a stock validation body of message plus errors when the request itself is malformed.

```json
{
  "success": false,
  "error": "payment_declined",
  "message": "Insufficient funds",
  "details": {
    "response_code": "051",
    "response_message": "Insufficient funds"
  }
}
```

##### 409 — Another add-on for this order and idempotency_key is mid-flight and the 10-second lock wait expired. Retry.

```json
{
  "success": false,
  "error": "in_progress",
  "message": "Another add-on for this order is currently processing. Please retry."
}
```

##### 404 — No order with that `order_id` belongs to the authenticated team. Note that the error code differs from the one the retrieve endpoint returns.

```json
{
  "success": false,
  "error": "order_not_found",
  "message": "Order not found."
}
```

#### Caveats

- Units are mixed inside one exchange. You send items[].price and tax in integer cents, the order object comes back in float dollars, and the top-level transaction.amount is in integer cents. Do not reuse one parser for both.
- The idempotency key is looked up across the whole team, not per order: any completed transaction carrying that key wins. Reuse a key from a different order, or from a checkout charge, and you get a 200 with replayed true, no new line items, and a transaction belonging to some other order. Namespace your keys per order.
- A decline records a failed transaction that stores no idempotency key, so declines are deliberately not covered by the replay guard: retrying a declined add-on with the same key genuinely re-attempts the charge.
- orders:upsell is not one of the default token abilities. Grant it explicitly when creating the token.
- The order object in this response omits shipping_address, billing_address and fulfillments, because the reloaded model only eager-loads items, transactions and the payment method. Call the retrieve endpoint when you need them.
- The card charged is the order's saved payment method, falling back to the customer's most recently created active card. The gateway is the one the original payment settled on, falling back to the team's active card merchant account. If neither can be resolved, the request fails with upsell_invalid.
- A blacklisted customer or IP is rejected before the gateway is contacted, but that guard throws a non-JSON error the controller cannot classify, so it surfaces as a 500 with error processing_error rather than a 422.
- Add-on items are tagged with a properties object of { "_upsell": true }, which is how you tell them apart from the original lines.

Source: https://docs.firearmcart.com/api/resources/orders/index.mdx
