Skip to content

Shipping, taxation & FFL dealers

Read shipping zones and rates, manual tax zones, and the ATF FFL dealer directory.

Updated View as Markdown

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 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.
Required ability
shipping:read
Rate limit
60 requests/minute per team

Query parameters

activebooleanoptional
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
statestringoptional
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
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.
Requestbash
curl -G https://api.firearmcart.com/v1/shipping \
  -H "Authorization: Bearer $FIREARMCART_TOKEN" \
  -H "Accept: application/json" \
  -d active=1 \
  -d state=TX
200Zones ordered by name, each with its rates array.
{
  "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
        }
      ]
    }
  ]
}
403The token exists but was not granted the shipping:read ability. This is the framework's stock authorization envelope — there is no error code field.
{
  "message": "Invalid ability provided."
}
403API 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.
{
  "error": "forbidden",
  "message": "API access is not enabled for this team."
}

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.
Required ability
taxation:read
Rate limit
60 requests/minute per team

Query parameters

activebooleanoptional
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
statestringoptional
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
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.
Requestbash
curl -G https://api.firearmcart.com/v1/taxation \
  -H "Authorization: Bearer $FIREARMCART_TOKEN" \
  -H "Accept: application/json" \
  -d active=1
200Zones ordered by name.
{
  "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
    }
  ]
}
401Missing, malformed or revoked bearer token. Stock authentication envelope.
{
  "message": "Unauthenticated."
}

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.
Required ability
ffl:read
Rate limit
60 requests/minute per team

Query parameters

namestringoptional
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
zipstringoptional
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
radiusintegeroptional
Search radius in miles, 1-100. Defaults to 25. Ignored unless zip is supplied.
Example: 50
per_pageintegeroptional
Results per page, 1-100. Defaults to 25.
Example: 25
pageintegeroptional
1-based page number. Defaults to 1.
Example: 2
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.
Requestbash
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
200A ZIP search: dealers sorted nearest-first, with the standard pagination envelope.
{
  "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": "« 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 »", "page": 2, "active": false }
    ],
    "path": "https://api.firearmcart.com/v1/ffl-dealers",
    "per_page": 25,
    "to": 25,
    "total": 61
  }
}
422Validation failure. Stock shape — a message plus a field-keyed errors map. There is no validation_error code field.
{
  "message": "The zip field must be 5 digits.",
  "errors": {
    "zip": [
      "The zip field must be 5 digits."
    ]
  }
}
403The 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.
{
  "message": "Invalid ability provided."
}
Navigation

Type to search…

↑↓ navigate↵ selectEsc close