---
title: "Fulfillments"
description: "Read the shipment groups behind an order, with carrier tracking and a full status timeline"
---

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

# Fulfillments

A fulfillment is one shipment group on an order: a single supplier shipping to a single destination. It carries the status, the carrier tracking numbers, and an append-only timeline of everything that happened while the order was being shipped.

Orders and fulfillments are deliberately not one-to-one. When an order is placed, the platform splits its items into groups and creates a fulfillment for each — one per supplier, one per destination, and always separating ammunition from firearms, since carriers will not accept them in the same box. A three-item order sourced from two distributors, with one item routed to a receiving FFL, produces three fulfillments. Read them as a many-to-one collection hanging off `order_id`, never as a single "shipping status" for the order.

## When to use these endpoints

Poll or fetch fulfillments when you need shipping-side detail that the order endpoints do not carry: tracking numbers, carrier, per-shipment ship dates, and the timeline of distributor events. Filtering the list by `order_id` is the normal way to answer "where is this customer's order right now" — you get every leg of it in one call.

If you only need to know whether an order shipped, prefer the outgoing webhooks over polling. `tracking.assigned` and `order.delivered` fire as fulfillments progress, and stores with enhanced tracking also receive `shipment.in_transit`, `shipment.out_for_delivery` and `shipment.delivered`. See [Webhooks](/api/webhooks) for the full event list and payloads.

## Read-only by design

There is no write surface here. You cannot create a fulfillment, attach a tracking number, or move a status through the API. Fulfillments are created automatically when an order is paid, and they advance through the dashboard and through the distributor and carrier integrations. Both endpoints below require the `fulfillments:read` ability — see [Authentication](/api/authentication) for how abilities are granted to a token.

## Identifiers

The `fulfillment_id` used in the path is a UUID — fulfillments are not numbered per store the way orders are. The `order_id` field *inside* a fulfillment payload **is** a store-scoped order number and matches the ids returned by [Orders](/api/resources/orders/). Do not feed one to the other. Always take `fulfillment_id` from a list response or a webhook rather than deriving it.

## What is not in the payload

The fulfillment record knows which order items it covers and which FFL it ships to, but neither is exposed on this resource. To reconcile a shipment against line items, or to show the receiving dealer, fetch the order itself from [Orders](/api/resources/orders/) and join on `order_id`.

## List fulfillments

`GET /v1/fulfillments`

Returns a paginated list of the team's fulfillments, newest first by `created_at`. Each fulfillment represents one shipment group on an order — a single supplier, a single destination — so an order with mixed suppliers or a mix of FFL and direct-ship items produces several rows here.

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

#### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `order_id` | integer | no | Restrict to fulfillments belonging to one order, matched on the order's `order_id` (the same id `/v1/orders` returns) and scoped to your team. Example: `1042` |
| `status` | string | no | Exact-match filter on the fulfillment status. One of: pending, processing, shipped, in_transit, out_for_delivery, on_hold, delivered, picked_up, returned, cancelled, error. Example: `shipped` |
| `created_since` | string | no | Return only fulfillments created at or after this timestamp. Compared against the stored UTC `created_at`; send ISO 8601. Example: `2026-08-01T00:00:00Z` |
| `page` | integer | no | Page number. Defaults to 1. Example: `2` |
| `per_page` | integer | no | Results per page. Defaults to 25, capped at 100. Example: `50` |

#### Request

```bash
curl -sS -G https://api.firearmcart.com/v1/fulfillments \
  -H "Authorization: Bearer $FIREARMCART_TOKEN" \
  -H "Accept: application/json" \
  --data-urlencode "order_id=1042" \
  --data-urlencode "status=shipped" \
  --data-urlencode "per_page=25"
```

#### Responses

##### 200 — A page of fulfillments in the standard `data` / `links` / `meta` envelope.

```json
{
  "data": [
    {
      "fulfillment_id": "9c2f1a44-73d1-4c8e-9d3e-2f5b8a61c7aa",
      "order_id": 1042,
      "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-04T18:22:11+00:00"
        }
      ],
      "timeline": [
        {
          "status": "processing",
          "timestamp": "2026-08-03T15:04:02+00:00",
          "note": "Order submitted to RSR successfully"
        },
        {
          "status": "shipped",
          "timestamp": "2026-08-04T18:22:11+00:00",
          "note": "Additional shipment via UPS - Tracking: 1Z999AA10123456784"
        }
      ],
      "notes": null,
      "processed_at": "2026-08-03T15:04:02+00:00",
      "shipped_at": "2026-08-04T18:22:11+00:00",
      "delivered_at": null,
      "delivery_failed": false,
      "delivery_reason": null,
      "created_at": "2026-08-03T14:58:47+00:00",
      "updated_at": "2026-08-04T18:22:11+00:00"
    },
    {
      "fulfillment_id": "b0e7d2c9-4f11-4a6b-8c02-71d94aa0e3b5",
      "order_id": 1042,
      "status": "pending",
      "fulfillment_type": "internal",
      "service": null,
      "shipments": [],
      "timeline": [],
      "notes": null,
      "processed_at": null,
      "shipped_at": null,
      "delivered_at": null,
      "delivery_failed": false,
      "delivery_reason": null,
      "created_at": "2026-08-03T14:58:47+00:00",
      "updated_at": "2026-08-03T14:58:47+00:00"
    }
  ],
  "links": {
    "first": "https://api.firearmcart.com/v1/fulfillments?page=1",
    "last": "https://api.firearmcart.com/v1/fulfillments?page=1",
    "prev": null,
    "next": null
  },
  "meta": {
    "current_page": 1,
    "from": 1,
    "last_page": 1,
    "links": [
      { "url": null, "label": "&laquo; Previous", "page": null, "active": false },
      {
        "url": "https://api.firearmcart.com/v1/fulfillments?page=1",
        "label": "1",
        "page": 1,
        "active": true
      },
      { "url": null, "label": "Next &raquo;", "page": null, "active": false }
    ],
    "path": "https://api.firearmcart.com/v1/fulfillments",
    "per_page": 25,
    "to": 2,
    "total": 2
  }
}
```

##### 403 — The token is valid but is missing the `fulfillments:read` ability. The same status with a different body is returned when API access is disabled for the team (`{"error": "forbidden", ...}`) or the subscription has lapsed (`{"error": "subscription_expired", ...}`).

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

##### 429 — Rate limit exceeded. Stock throttle response; read `Retry-After` and `X-RateLimit-Remaining` from the headers.

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

#### Caveats

- **Pagination links drop your filters.** The paginator is built without `withQueryString()`, so `links.next` and `meta.links[].url` carry only `?page=N` — following them verbatim silently widens the result set to every fulfillment. Re-send `order_id`, `status`, `created_since` and `per_page` yourself on each page.
- `per_page` is capped at 100 but never floored or validated. It is cast with `(int)`, so `per_page=0` or a non-numeric value becomes `0`, which is falsy — the paginator then falls back to an internal default of **15**, not the documented default of 25. A negative value is worse: it survives that fallback, and `limit()` ignores values below zero, so `per_page=-1` drops the LIMIT entirely and returns every matching fulfillment in one response with `last_page: 1`. Always send a positive integer.
- Filters are applied conditionally on truthiness. `order_id=0`, `status=` and `created_since=` are silently ignored rather than returning an empty page.
- None of the filters are validated. An unrecognised `status` is passed straight to the query and returns an empty page instead of a 422, and an unparseable `created_since` reaches the database as a timestamp literal and produces a 500.
- Ordering is fixed at `created_at desc`. There are no `sort_by` / `sort_direction` parameters on this endpoint, unlike `/v1/products`.
- `fulfillment_id` is a UUID — fulfillments are not numbered per store the way orders are. The sibling `order_id` field *is* a store-scoped order number and matches the id used by `/v1/orders/{order_id}`.
- An order almost always has more than one fulfillment. Fulfillments are split by supplier, by FFL-versus-direct destination, and by ammunition-versus-firearm (those cannot ship together), so treat `order_id` as a many-to-one key. `fulfillment_type` is the supplier slug (`rsr`, `chattanooga`, `lipseys`, `mge`, `shipstation`, `internal`, …). `mge` is always a warehouse-restock leg.
- `shipments` is always present — both endpoints eager-load the relation — but it is an empty array until a tracking number is recorded. A single fulfillment can have several shipments when the order is split across boxes.
- `shipments[].tracking_url` is a computed accessor, not a stored column. It is only built for carriers whose name contains `ups`, `fedex`, `usps` or `dhl`; every other carrier returns `null` even when `tracking_number` is populated. Build your own link from `carrier` + `tracking_number` if you support other carriers.
- `service` is a legacy column. No production code path writes it — only the demo-data generator does — so expect `null` in real data. Carrier service level lives on the shipment record and is not exposed here.
- `notes` is not merchant free-text. `Fulfillment::markAsError()` overwrites it with the failure message, so it is normally `null` and is populated when `status` is `error`.
- `timeline` is an append-only array of `{ status, timestamp, note }` objects written by the fulfillment jobs. `timestamp` is ISO 8601; `status` is a `FulfillmentStatus` value. It defaults to `[]`, never `null`.
- `delivery_failed` / `delivery_reason` are set only by the carrier-tracking webhook, independently of `status`. A fulfillment can be `delivered` with `delivery_failed: true` if the carrier later reported a problem.
- The resource exposes neither the line items on the fulfillment nor the destination FFL, despite both existing on the underlying record. Fetch [`GET /v1/orders/{order_id}`](/api/resources/orders/) for items and the FFL.
- Fulfillments are read-only over the API. There is no endpoint to create a fulfillment, push tracking, or change status — those happen in the dashboard and via distributor integrations.
- `fulfillment_type` is the supplier that ships the fulfillment — the plugin slug (`rsr`, `chattanooga`, `lipseys`, `2aw`, `eprolo`, `printify`, `shipstation`) or `internal` for stock you ship yourself. It is not a destination flag.

## Retrieve a fulfillment

`GET /v1/fulfillments/{fulfillment_id}`

Returns a single fulfillment with its shipments and full status timeline. The lookup is scoped to the authenticated team, so another team's id is indistinguishable from one that does not exist.

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

#### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `fulfillment_id` | string (uuid) | yes | The fulfillment's UUID — the `fulfillment_id` value returned by `GET /v1/fulfillments`. A value that is not a well-formed UUID is a 404. Example: `9c2f1a44-73d1-4c8e-9d3e-2f5b8a61c7aa` |

#### Request

```bash
curl -sS https://api.firearmcart.com/v1/fulfillments/9c2f1a44-73d1-4c8e-9d3e-2f5b8a61c7aa \
  -H "Authorization: Bearer $FIREARMCART_TOKEN" \
  -H "Accept: application/json"
```

#### Responses

##### 200 — The fulfillment, wrapped in `data`. The example shows an errored distributor push, where `notes` carries the failure message.

```json
{
  "data": {
    "fulfillment_id": "9c2f1a44-73d1-4c8e-9d3e-2f5b8a61c7aa",
    "order_id": 1042,
    "status": "error",
    "fulfillment_type": "rsr",
    "service": null,
    "shipments": [],
    "timeline": [
      {
        "status": "error",
        "timestamp": "2026-08-03T15:04:02+00:00",
        "note": "Error: Dealer is not on file with RSR."
      }
    ],
    "notes": "Dealer is not on file with RSR.",
    "processed_at": null,
    "shipped_at": null,
    "delivered_at": null,
    "delivery_failed": false,
    "delivery_reason": null,
    "created_at": "2026-08-03T14:58:47+00:00",
    "updated_at": "2026-08-03T15:04:02+00:00"
  }
}
```

##### 404 — No fulfillment with that id belongs to your team. This endpoint returns a custom error envelope rather than the framework's bare `{"message": "..."}`.

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

##### 401 — Missing, revoked or malformed bearer token. A token owned by a user rather than a team instead returns `{"error": "unauthorized", "message": "Invalid API token."}`.

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

#### Caveats

- A path segment that is not a well-formed UUID — `/v1/fulfillments/abc`, or a numeric id from before the UUID migration — returns the same 404 body as a missing fulfillment. The format is checked before the query runs.
- A fulfillment belonging to another team returns 404, not 403 — the team scope is part of the `where` clause, so existence is never leaked.
- `fulfillment_id` is a UUID — fulfillments are not numbered per store the way orders are. The sibling `order_id` field *is* a store-scoped order number and matches the id used by `/v1/orders/{order_id}`.
- An order almost always has more than one fulfillment. Fulfillments are split by supplier, by FFL-versus-direct destination, and by ammunition-versus-firearm (those cannot ship together), so treat `order_id` as a many-to-one key. `fulfillment_type` is the supplier slug (`rsr`, `chattanooga`, `lipseys`, `mge`, `shipstation`, `internal`, …). `mge` is always a warehouse-restock leg.
- `shipments` is always present — both endpoints eager-load the relation — but it is an empty array until a tracking number is recorded. A single fulfillment can have several shipments when the order is split across boxes.
- `shipments[].tracking_url` is a computed accessor, not a stored column. It is only built for carriers whose name contains `ups`, `fedex`, `usps` or `dhl`; every other carrier returns `null` even when `tracking_number` is populated. Build your own link from `carrier` + `tracking_number` if you support other carriers.
- `service` is a legacy column. No production code path writes it — only the demo-data generator does — so expect `null` in real data. Carrier service level lives on the shipment record and is not exposed here.
- `notes` is not merchant free-text. `Fulfillment::markAsError()` overwrites it with the failure message, so it is normally `null` and is populated when `status` is `error`.
- `timeline` is an append-only array of `{ status, timestamp, note }` objects written by the fulfillment jobs. `timestamp` is ISO 8601; `status` is a `FulfillmentStatus` value. It defaults to `[]`, never `null`.
- `delivery_failed` / `delivery_reason` are set only by the carrier-tracking webhook, independently of `status`. A fulfillment can be `delivered` with `delivery_failed: true` if the carrier later reported a problem.
- The resource exposes neither the line items on the fulfillment nor the destination FFL, despite both existing on the underlying record. Fetch [`GET /v1/orders/{order_id}`](/api/resources/orders/) for items and the FFL.
- Fulfillments are read-only over the API. There is no endpoint to create a fulfillment, push tracking, or change status — those happen in the dashboard and via distributor integrations.
- `fulfillment_type` is the supplier that ships the fulfillment — the plugin slug (`rsr`, `chattanooga`, `lipseys`, `2aw`, `eprolo`, `printify`, `shipstation`) or `internal` for stock you ship yourself. It is not a destination flag.

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