---
title: "Shipping, taxation & FFL dealers"
description: "Read shipping zones and rates, manual tax zones, and the ATF FFL dealer directory."
---

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

# Shipping, taxation & FFL dealers

These three read-only endpoints expose the data a checkout needs before it can quote a total: what a shipment costs, what tax to add, and which licensed dealer will receive a firearm transfer. They are the natural first calls in a headless checkout — collect a shipping rate and a dealer, then hand the results to `POST /v1/payments/charge`.

Each endpoint has its own ability, so a token can read shipping without reading tax. `shipping:read` and `taxation:read` are both granted by default when you create a token; **`ffl:read` is not** — tick it explicitly, or `GET /v1/ffl-dealers` will come back `403`. See [Authentication](/api/authentication) for how token abilities are issued.

## Choosing between them

- **Shipping** returns your configured zones with their rates nested inline. A rate's `rate_id` is what you pass back as `shipping_rate_id` when you charge an order — that is the only reason most integrations call it.
- **Taxation** returns the manual tax table. It is a fallback path: if the store has an automated tax plugin connected, charges are taxed through the plugin and this table is never consulted. Read it to *display* an expected tax rate, not to compute the authoritative one.
- **FFL dealers** searches the national ATF licensee directory. Unlike the other two it is platform-wide data, identical for every team, and it is the only one of the three that is paginated.

## Caveats that apply to the whole group

**Two of the three are not paginated.** `GET /v1/shipping` and `GET /v1/taxation` return every matching row in a single `data` array with no `links` or `meta` block. Only `GET /v1/ffl-dealers` paginates — and its `links` deliberately drop your query filters, so build page URLs yourself rather than following `links.next`.

**The `state` filter is case-sensitive.** Both zone endpoints match the value against a JSON array of two-letter USPS codes using containment, so `TX` matches and `tx` matches nothing. Uppercase before you send.

**The `active` filter treats a bare parameter as `false`.** `?active=` with no value is not the same as omitting the parameter — the value is coerced with standard boolean rules, so an empty string reads as `false` and you get only the *inactive* zones. Omit the parameter entirely when you want everything.

**Money units are not uniform, and none of them are dollars.** Shipping rates emit integer cents (`price: 999` is $9.99). Tax zones emit a decimal fraction (`rate: 0.0825` is 8.25%). The `rate_percent` field beside it is rounded to two decimals and is display-only — a 6.625% zone reports `6.63`. Do arithmetic on `rate`.

**Identifier types are mixed.** `zone_id` on both zone endpoints is the store-scoped number. A shipping `rate_id` is a UUID, and an FFL dealer has no surrogate id at all — its ATF `license_number` is the identifier.

**Errors use the framework's stock envelopes, with one exception.** A missing token is `{"message": "Unauthenticated."}`, a missing ability is `{"message": "Invalid ability provided."}`, and a validation failure is `{"message": ..., "errors": {...}}`. None of them carry a machine-readable `error` code. The exception is the API-access gate: a team without API access, or with a lapsed subscription, gets `{"error": "forbidden", "message": ...}`.

All three endpoints share the standard 60 requests/minute per-team limit.

## List shipping zones

`GET /v1/shipping`

Returns every shipping zone configured for the authenticated team, each with its rates nested inline. Use it to build a shipping picker, then pass a rate's rate_id as shipping_rate_id when you charge the order.

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

#### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `active` | boolean | no | Restrict to zones with this active flag. Coerced with standard boolean rules: 1, true, on and yes are true; everything else — including a bare active= with no value — is false. Example: `1` |
| `state` | string | no | Two-letter USPS state or territory code. Matched by JSON containment against the zone's states array, so it is case-sensitive and must be uppercase. Example: `TX` |

#### Request

```bash
curl -G https://api.firearmcart.com/v1/shipping \
  -H "Authorization: Bearer $FIREARMCART_TOKEN" \
  -H "Accept: application/json" \
  -d active=1 \
  -d state=TX
```

#### Responses

##### 200 — Zones ordered by name, each with its rates array.

```json
{
  "data": [
    {
      "zone_id": 1,
      "name": "Continental US",
      "states": ["TX", "OK", "NM", "LA", "AR"],
      "active": true,
      "rates": [
        {
          "rate_id": "4f8a2d17-9b3c-4e51-a6d8-0c7e5b2f9a14",
          "name": "Standard Ground",
          "description": "5-7 business days",
          "price": 999,
          "condition_type": "price",
          "min_value": 0,
          "max_value": 9999,
          "carrier_code": null,
          "service_code": null,
          "service_name": null
        },
        {
          "rate_id": "d92c6e40-5a17-48f3-b8d1-3e7a9c0f5b26",
          "name": "Free Shipping",
          "description": "Orders over $100",
          "price": 0,
          "condition_type": "price",
          "min_value": 10000,
          "max_value": null,
          "carrier_code": null,
          "service_code": null,
          "service_name": null
        }
      ]
    },
    {
      "zone_id": 4,
      "name": "Heavy Freight",
      "states": ["TX"],
      "active": true,
      "rates": [
        {
          "rate_id": "17b3f5c8-2d94-4e60-a72f-8c1e6b4d9a03",
          "name": "Freight Surcharge",
          "description": null,
          "price": 4500,
          "condition_type": "weight",
          "min_value": 5000,
          "max_value": null,
          "carrier_code": null,
          "service_code": null,
          "service_name": null
        }
      ]
    }
  ]
}
```

##### 403 — The token exists but was not granted the shipping:read ability. This is the framework's stock authorization envelope — there is no error code field.

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

##### 403 — API access is switched off for the team, or its subscription has lapsed. This envelope comes from the api.access middleware and does carry an error code.

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

#### Caveats

- Not paginated. Every matching zone is returned in one data array ordered by name, with no links or meta block.
- The two ids are different kinds. zone_id is the store-scoped number; rate_id is the rate's UUID. rate_id is what POST /v1/payments/charge expects in shipping_rate_id.
- price is integer cents. min_value and max_value are the stored column multiplied by 100 regardless of what the column means — cents when condition_type is price, hundredths of a pound when it is weight. min_value: 5000 on a weight rate is 50.00 lb, not $50.00.
- When condition_type is product_type, the stored price is the arithmetic sum of the four per-category prices (handguns, long guns, ammunition, accessories) and is not what any real cart is charged. The per-category breakdown is not exposed by this endpoint, and min_value/max_value are always null on those rates.
- If the ShipStation plugin is connected, a zone named "ShipStation Real-Time Rates" is returned alongside your own with zone_id: 0 and all 56 state and territory codes in states. This endpoint does not filter it out. Its rates carry carrier_code and service_code, and their price is a stored placeholder — the live carrier quote is fetched at checkout and never written back to the rate row.
- A rate_id that does not resolve to a rate under one of your zones is not an error at charge time: POST /v1/payments/charge silently falls back to 0 shipping.

## List tax zones

`GET /v1/taxation`

Returns the manual tax zones configured for the authenticated team. Each zone maps a set of states to a single flat rate, which the platform applies to an order when no automated tax plugin is active.

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

#### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `active` | boolean | no | Restrict to zones with this active flag. Coerced with standard boolean rules: 1, true, on and yes are true; everything else — including a bare active= with no value — is false. Example: `1` |
| `state` | string | no | Two-letter USPS state or territory code. Matched by JSON containment against the zone's states array, so it is case-sensitive and must be uppercase. Example: `TX` |

#### Request

```bash
curl -G https://api.firearmcart.com/v1/taxation \
  -H "Authorization: Bearer $FIREARMCART_TOKEN" \
  -H "Accept: application/json" \
  -d active=1
```

#### Responses

##### 200 — Zones ordered by name.

```json
{
  "data": [
    {
      "zone_id": 1,
      "name": "Texas Sales Tax",
      "rate": 0.0825,
      "rate_percent": 8.25,
      "states": ["TX"],
      "active": true
    },
    {
      "zone_id": 2,
      "name": "New Jersey Sales Tax",
      "rate": 0.06625,
      "rate_percent": 6.63,
      "states": ["NJ"],
      "active": true
    }
  ]
}
```

##### 401 — Missing, malformed or revoked bearer token. Stock authentication envelope.

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

#### Caveats

- Not paginated. Every matching zone is returned in one data array ordered by name, with no links or meta block.
- rate is the decimal fraction the platform actually multiplies by (0.0825 = 8.25%), stored as decimal(8,6). It is the only field safe for arithmetic.
- rate_percent is rate * 100 rounded to two decimals and is therefore lossy. New Jersey's 6.625% zone stores rate: 0.06625 but reports rate_percent: 6.63. Treat it as a display string, never as an input to your own tax math.
- zone_id is the store-scoped number, not a database primary key.
- At charge time only one zone applies: the first active zone whose states array contains the shipping state. If two active zones both list a state, the second never fires and the tie-break order is undefined.
- These zones are the fallback path. If the team has an active tax plugin (for example Avalara), POST /v1/payments/charge computes tax through that plugin and ignores this table entirely.

## Search FFL dealers

`GET /v1/ffl-dealers`

Searches the platform-wide directory of ATF Federal Firearms Licensees by name, license number, or ZIP code and radius. Use it to let a buyer choose the dealer that will receive a firearm transfer.

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

#### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | no | Free-text search, minimum 3 characters. Every whitespace-separated term must match license_name, business_name or premise_city (AND across terms, OR across columns), case-insensitively. If the value looks like a license number — no whitespace, at least 6 alphanumerics and at least 4 digits — the license columns are searched too, so a full dashed number, an undashed one, a leading run of segments, or the 8-character ATF eZ Check number all resolve. Example: `Lone Star Firearms` |
| `zip` | string | no | US ZIP code to search around; must be exactly 5 digits. The ZIP is geocoded, then dealers are returned within radius miles, nearest first, each carrying a distance field. Example: `75001` |
| `radius` | integer | no | Search radius in miles, 1-100. Defaults to 25. Ignored unless zip is supplied. Example: `50` |
| `per_page` | integer | no | Results per page, 1-100. Defaults to 25. Example: `25` |
| `page` | integer | no | 1-based page number. Defaults to 1. Example: `2` |

#### Request

```bash
curl -G https://api.firearmcart.com/v1/ffl-dealers \
  -H "Authorization: Bearer $FIREARMCART_TOKEN" \
  -H "Accept: application/json" \
  -d zip=75001 \
  -d radius=25 \
  -d per_page=25
```

#### Responses

##### 200 — A ZIP search: dealers sorted nearest-first, with the standard pagination envelope.

```json
{
  "data": [
    {
      "license_number": "1-75-113-01-2A-04417",
      "license_name": "SMITH JOHN A",
      "business_name": "LONE STAR FIREARMS",
      "premise_street": "4820 W BELTLINE RD",
      "premise_city": "ADDISON",
      "premise_state": "TX",
      "premise_zip_code": "75001",
      "phone": "9725550142",
      "has_sot": false,
      "distance": 1.8342071996235
    },
    {
      "license_number": "1-75-085-01-5C-11366",
      "license_name": "NORTH TEXAS ARMS LLC",
      "business_name": "NORTH TEXAS ARMS",
      "premise_street": "1190 N PLANO RD",
      "premise_city": "RICHARDSON",
      "premise_state": "TX",
      "premise_zip_code": "75081",
      "phone": null,
      "has_sot": true,
      "distance": 6.4417830552901
    }
  ],
  "links": {
    "first": "https://api.firearmcart.com/v1/ffl-dealers?page=1",
    "last": "https://api.firearmcart.com/v1/ffl-dealers?page=3",
    "prev": null,
    "next": "https://api.firearmcart.com/v1/ffl-dealers?page=2"
  },
  "meta": {
    "current_page": 1,
    "from": 1,
    "last_page": 3,
    "links": [
      { "url": null, "label": "&laquo; Previous", "page": null, "active": false },
      { "url": "https://api.firearmcart.com/v1/ffl-dealers?page=1", "label": "1", "page": 1, "active": true },
      { "url": "https://api.firearmcart.com/v1/ffl-dealers?page=2", "label": "2", "page": 2, "active": false },
      { "url": "https://api.firearmcart.com/v1/ffl-dealers?page=3", "label": "3", "page": 3, "active": false },
      { "url": "https://api.firearmcart.com/v1/ffl-dealers?page=2", "label": "Next &raquo;", "page": 2, "active": false }
    ],
    "path": "https://api.firearmcart.com/v1/ffl-dealers",
    "per_page": 25,
    "to": 25,
    "total": 61
  }
}
```

##### 422 — Validation failure. Stock shape — a message plus a field-keyed errors map. There is no validation_error code field.

```json
{
  "message": "The zip field must be 5 digits.",
  "errors": {
    "zip": [
      "The zip field must be 5 digits."
    ]
  }
}
```

##### 403 — The token was not granted the ffl:read ability. Note that ffl:read is not one of the default token permissions, so it has to be ticked explicitly when the token is created.

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

#### Caveats

- This directory is platform-wide ATF data, not team-scoped. Two different teams get identical results for the same query. There is no surrogate id — license_number is the identifier, and no v1 endpoint accepts an FFL id as input.
- name wins over zip. If you send both, the ZIP and radius are silently ignored and no distance field is returned.
- distance is present only on ZIP searches, and it is omitted from the object entirely — not set to null — on name and unfiltered searches. It is unrounded miles.
- An unrecognisable ZIP is not an error. Geocoding failure returns HTTP 200 with an empty data array and total: 0, indistinguishable from a valid ZIP with no dealers nearby.
- There is no auto-expansion. The storefront's dealer picker widens to 50 miles when a search comes back empty; this endpoint does not — you get exactly the radius you asked for.
- Dealers whose rows have no geocoded coordinates can never appear in a ZIP search, because the distance test excludes them. They are still findable by name.
- Sending neither name nor zip paginates the entire national directory — well over a hundred thousand rows — through an unordered query. The database may then repeat or skip rows between pages. Name and unfiltered searches have no ORDER BY at all; only ZIP searches are deterministically ordered (by distance).
- The pagination links do not carry your filters — they append only page, so following links.next drops name, zip, radius and per_page and hands you a page of the unfiltered directory. Build page URLs yourself by adding page to your original query string.
- Results are not restricted to dealers a retail buyer can transfer to. The storefront picker limits itself to license types 01, 02 and 07; this endpoint returns importers and destructive-device licensees too, and does not expose license_type. The 4th dash-separated segment of license_number is the license type if you need to filter client-side.
- One row is one license period. Renewed licenses are retired by soft-delete and excluded, so the row you get is the current period — meaning license_number can change for the same physical dealer at renewal. has_sot marks a Special Occupational Taxpayer (able to handle NFA items).
- phone is raw ATF import data: unformatted, frequently null, and occasionally carrying an extension.

Source: https://docs.firearmcart.com/api/resources/shipping-taxation-ffl/index.mdx
