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 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 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. 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 and join on order_id.
List fulfillments
/v1/fulfillments- Required ability
fulfillments:read- Rate limit
- 60 requests/minute per team
Query parameters
order_idintegeroptional- 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 statusstringoptional- 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_sincestringoptional- 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 pageintegeroptional- Page number. Defaults to 1.Example:
2 per_pageintegeroptional- Results per page. Defaults to 25, capped at 100.Example:
50
- **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.
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"{
"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": "« Previous", "page": null, "active": false },
{
"url": "https://api.firearmcart.com/v1/fulfillments?page=1",
"label": "1",
"page": 1,
"active": true
},
{ "url": null, "label": "Next »", "page": null, "active": false }
],
"path": "https://api.firearmcart.com/v1/fulfillments",
"per_page": 25,
"to": 2,
"total": 2
}
}{
"message": "Invalid ability provided."
}{
"message": "Too Many Attempts."
}Retrieve a fulfillment
/v1/fulfillments/{fulfillment_id}- Required ability
fulfillments:read- Rate limit
- 60 requests/minute per team
Path parameters
fulfillment_idstring (uuid)required- 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
- 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.
curl -sS https://api.firearmcart.com/v1/fulfillments/9c2f1a44-73d1-4c8e-9d3e-2f5b8a61c7aa \
-H "Authorization: Bearer $FIREARMCART_TOKEN" \
-H "Accept: application/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"
}
}{
"error": "not_found",
"message": "Fulfillment not found."
}{
"message": "Unauthenticated."
}
