Skip to content

Products

Read your catalog through the API — products, variants, images and specifications

Updated View as Markdown

The Products endpoints expose the catalog belonging to the team that owns the API token. Use them to mirror your catalog into a storefront you host yourself, to keep an external marketplace listing in sync, or to resolve a product_id you received from an order or a charge into the item a customer actually bought.

Both endpoints are read-only. Version 1 of the API has no product create, update or delete route — products are created in the FirearmCart dashboard or by a distributor sync, and the API reflects the result. Both require a token carrying the products:read ability; see Authentication for how abilities are attached when a token is minted.

What comes back

Every product is returned fully expanded. Its collection, its variants (with option names and values), its image URLs at three sizes, and its specifications are all included on every response — there is no include, fields or expand parameter to trim that down. A page of 100 products is a large payload, so prefer a smaller per_page on latency-sensitive paths.

The single-product response is the same object as one element of the list response’s data array, so one parser handles both.

Money and units on this resource

price and compare_at_price are integer cents on both products and variants. Do not generalize that across the API: orders, order items and transactions emit floating-point dollars, so a response that joins an order to its products carries both units at once.

weight is a JSON string such as "2.00", not a number, because the underlying column is a fixed-precision decimal. weight_unit is free text and defaults to oz.

Identifiers

product_id, variant_id and collection_id are all team-scoped sequence numbers, not the platform’s internal database keys. They are stable for the life of the record and are the values every other endpoint expects.

variant_id is scoped to its parent product rather than to the team, so the number 1 identifies a different variant under every product. Key variants by the (product_id, variant_id) pair, never by variant_id alone.

Staying in sync

Poll with updated_since combined with sort_by=updated_at; store the largest updated_at you have seen and send it back on the next run. Walking every page on a schedule works but scales badly and, because deletions are soft and simply stop appearing in results, it will not tell you what was removed either. If you need to detect removals, reconcile the full id set on a slower cadence.

Two filter behaviours are worth knowing before you build against them: a collection_id that does not resolve returns an empty page rather than a 404, and search runs as a case-sensitive SQL LIKE, so glock and Glock are different queries. The per-operation notes below cover the rest.

Status values

status is only ever active or draft. There is no archived status despite what older documentation claimed — filtering on it returns an empty page. Soft-deleted products are excluded from both endpoints automatically and cannot be retrieved through the API at all.

List products

GET/v1/products
Returns a paginated list of the catalog products belonging to the team that owns the API token. Every product is returned with its collection, variants (including option values), image URLs and specifications already expanded — there is no include or sparse-fieldset parameter. Soft-deleted products are never returned.
Required ability
products:read
Rate limit
60 requests/minute per team

Query parameters

collection_idintegeroptional
Restrict results to one collection, identified by the `collection_id` value this endpoint itself returns). An id that does not resolve to one of your collections yields an empty page, not a 404.
Example: 10
statusstringoptional
Exact match on the product status column. Only active and draft exist; any other value returns an empty page.
Example: active
in_stockbooleanoptional
1/true keeps products with stock_quantity greater than 0; 0/false keeps products whose stock_quantity is exactly 0. Parsed with standard boolean coercion, so on, yes and 1 all read as true.
Example: 1
searchstringoptional
Substring match against name OR sku. Runs as a case-sensitive SQL LIKE, and % and _ in your term are passed through as wildcards rather than escaped.
Example: Glock
product_typestringoptional
Exact, case-sensitive match on the free-text product_type column. Match the casing your catalog actually stores.
Example: Handguns
created_sincestringoptional
Keep products created at or after this timestamp. Send ISO-8601 with an explicit UTC offset; the value is handed to the database unvalidated.
Example: 2026-01-01T00:00:00Z
updated_sincestringoptional
Keep products updated at or after this timestamp. Use this to poll for catalog changes instead of re-walking every page.
Example: 2026-08-01T00:00:00Z
sort_bystringoptional
One of created_at, updated_at, name, price, stock_quantity. Defaults to created_at. Any other value is silently ignored and the result set comes back unordered.
Example: updated_at
sort_directionstringoptional
asc or desc. Defaults to desc; anything that is not exactly asc is treated as desc.
Example: asc
pageintegeroptional
1-based page number. Defaults to 1.
Example: 2
per_pageintegeroptional
Results per page. Defaults to 25 and is capped at 100, but is not otherwise validated — see the notes below before sending anything unusual.
Example: 50
Caveats
  • price and compare_at_price are integer CENTS on products and variants. This is not a platform-wide rule — orders, order items and transactions emit float dollars, so a single integration has to convert per resource.
  • weight is a JSON string, not a number (the column is cast decimal:2), so you will receive "2.00" rather than 2. Parse it before doing arithmetic. weight_unit is free text and defaults to oz.
  • A compare_at_price stored as exactly 0.00 is emitted as 0 rather than null, because the resource tests the decimal string for truthiness.
  • sort_by values outside the allow-list are dropped rather than rejected, which leaves the query with no ORDER BY at all. The database is then free to return rows in any order, so paging through the result set can repeat or skip products. Always send a supported sort_by or omit it.
  • per_page is capped at 100 but never validated as an integer, and the failure modes are all silent. A non-numeric value such as per_page=abc is treated as 100; per_page=0 or an empty per_page= falls through to an internal default of 15, not to the documented default of 25; and a negative value removes the limit entirely, returning every matching product in a single response with meta.per_page echoing your negative number.
  • created_since and updated_since are passed straight into the SQL comparison with no validation. A value the database cannot parse surfaces as a 500, not a 422.
  • in_stock=0 matches stock_quantity = 0 exactly, so an oversold product sitting at a negative stock_quantity matches neither in_stock=1 nor in_stock=0.
  • collection is null when the product is not filed under a collection. variants and images are always arrays (empty when there is nothing to return), and specifications is always an array of {name, value} objects — never a keyed map.
  • Timestamps are ISO-8601 in UTC with an explicit +00:00 offset, e.g. 2026-08-02T09:41:16+00:00, not a Z suffix.
Requestbash
curl -G https://api.firearmcart.com/v1/products \
  -H "Authorization: Bearer $FIREARMCART_TOKEN" \
  -H "Accept: application/json" \
  -d status=active \
  -d in_stock=1 \
  -d sort_by=updated_at \
  -d sort_direction=desc \
  -d per_page=25
200A page of products in the standard paginated envelope: data, links and meta.
{
  "data": [
    {
      "product_id": 501,
      "name": "Glock 19 Gen 5 9mm Luger",
      "slug": "glock-19-gen-5-9mm-luger",
      "description": "Compact 9mm pistol with the Gen 5 nDLC finish and Marksman barrel.",
      "price": 54999,
      "compare_at_price": 59999,
      "sku": "GLK-PA195S203",
      "barcode": "764503037108",
      "stock_quantity": 15,
      "in_stock": true,
      "available": true,
      "status": "active",
      "product_type": "Handguns",
      "is_shippable": true,
      "weight": "2.00",
      "weight_unit": "lb",
      "has_variants": true,
      "collection": {
        "collection_id": 10,
        "name": "Handguns",
        "slug": "handguns"
      },
      "variants": [
        {
          "variant_id": 1,
          "sku": "GLK-PA195S203-BLK",
          "name": "Color: Black",
          "price": 54999,
          "compare_at_price": null,
          "stock_quantity": 9,
          "in_stock": true,
          "available": true,
          "barcode": "764503037108",
          "weight": "2.00",
          "weight_unit": "lb",
          "options": [
            {
              "name": "Color",
              "value": "Black"
            }
          ],
          "image_url": "https://cdn.firearmcart.com/media/8412/glock-19-black.jpg"
        }
      ],
      "images": [
        {
          "url": "https://cdn.firearmcart.com/media/8412/glock-19.jpg",
          "thumb_url": "https://cdn.firearmcart.com/media/8412/conversions/glock-19-thumb.jpg",
          "medium_url": "https://cdn.firearmcart.com/media/8412/conversions/glock-19-medium.jpg"
        }
      ],
      "specifications": [
        {
          "name": "Manufacturer",
          "value": "Glock"
        },
        {
          "name": "Caliber",
          "value": "9mm Luger"
        },
        {
          "name": "Capacity",
          "value": "15"
        }
      ],
      "created_at": "2026-01-14T18:22:05+00:00",
      "updated_at": "2026-08-02T09:41:16+00:00"
    }
  ],
  "links": {
    "first": "https://api.firearmcart.com/v1/products?page=1",
    "last": "https://api.firearmcart.com/v1/products?page=4",
    "prev": null,
    "next": "https://api.firearmcart.com/v1/products?page=2"
  },
  "meta": {
    "current_page": 1,
    "from": 1,
    "last_page": 4,
    "links": [
      {
        "url": null,
        "label": "« Previous",
        "page": null,
        "active": false
      },
      {
        "url": "https://api.firearmcart.com/v1/products?page=1",
        "label": "1",
        "page": 1,
        "active": true
      },
      {
        "url": "https://api.firearmcart.com/v1/products?page=2",
        "label": "2",
        "page": 2,
        "active": false
      },
      {
        "url": "https://api.firearmcart.com/v1/products?page=3",
        "label": "3",
        "page": 3,
        "active": false
      },
      {
        "url": "https://api.firearmcart.com/v1/products?page=4",
        "label": "4",
        "page": 4,
        "active": false
      },
      {
        "url": "https://api.firearmcart.com/v1/products?page=2",
        "label": "Next »",
        "page": 2,
        "active": false
      }
    ],
    "path": "https://api.firearmcart.com/v1/products",
    "per_page": 25,
    "to": 25,
    "total": 87
  }
}
403The token is valid but was not minted with the products:read ability. This bare message shape comes from the auth layer, not from a FirearmCart error envelope.
{
  "message": "Invalid ability provided."
}
429More than 60 requests in a minute for this team. Read the Retry-After header before retrying.
{
  "message": "Too Many Attempts."
}

Retrieve a product

GET/v1/products/{product_id}
Returns a single product, wrapped in a data envelope, with the same expansion as the list endpoint: collection, variants with option values, image URLs and specifications. Lookup is scoped to the token's team, so another team's product id is indistinguishable from one that does not exist.
Required ability
products:read
Rate limit
60 requests/minute per team

Path parameters

product_idintegerrequired
The product's store-scoped `product_id` — the same value the list endpoint returns, not the platform's underlying database key.
Example: 501
Caveats
  • product_id must be numeric. There is no route constraint on the segment and the controller signature coerces it to an int, so a non-numeric id such as /v1/products/abc fails as a server error rather than the 404 you would expect.
  • variant_id is only unique within its parent product — the counter is scoped per product, so variant_id 1 exists under every product that has variants. Always key variants by (product_id, variant_id).
  • A variant's name is derived from its option values at render time (Color: Black / Size: Large). A variant with no options therefore reports an empty string, not null.
  • A variant's image_url falls back to the relative path /images/placeholder.jpg when no image has been tagged to that specific variant. It is not an absolute URL and it is never null, so test for the placeholder rather than for emptiness.
  • The response is identical in shape to one element of the list endpoint's data array, so a single parser handles both.
Requestbash
curl https://api.firearmcart.com/v1/products/501 \
  -H "Authorization: Bearer $FIREARMCART_TOKEN" \
  -H "Accept: application/json"
200The product, wrapped in data.
{
  "data": {
    "product_id": 501,
    "name": "Glock 19 Gen 5 9mm Luger",
    "slug": "glock-19-gen-5-9mm-luger",
    "description": "Compact 9mm pistol with the Gen 5 nDLC finish and Marksman barrel.",
    "price": 54999,
    "compare_at_price": 59999,
    "sku": "GLK-PA195S203",
    "barcode": "764503037108",
    "stock_quantity": 15,
    "in_stock": true,
    "available": true,
    "status": "active",
    "product_type": "Handguns",
    "is_shippable": true,
    "weight": "2.00",
    "weight_unit": "lb",
    "has_variants": true,
    "collection": {
      "collection_id": 10,
      "name": "Handguns",
      "slug": "handguns"
    },
    "variants": [
      {
        "variant_id": 1,
        "sku": "GLK-PA195S203-BLK",
        "name": "Color: Black",
        "price": 54999,
        "compare_at_price": null,
        "stock_quantity": 9,
        "in_stock": true,
        "available": true,
        "barcode": "764503037108",
        "weight": "2.00",
        "weight_unit": "lb",
        "options": [
          {
            "name": "Color",
            "value": "Black"
          }
        ],
        "image_url": "https://cdn.firearmcart.com/media/8412/glock-19-black.jpg"
      }
    ],
    "images": [
      {
        "url": "https://cdn.firearmcart.com/media/8412/glock-19.jpg",
        "thumb_url": "https://cdn.firearmcart.com/media/8412/conversions/glock-19-thumb.jpg",
        "medium_url": "https://cdn.firearmcart.com/media/8412/conversions/glock-19-medium.jpg"
      }
    ],
    "specifications": [
      {
        "name": "Manufacturer",
        "value": "Glock"
      },
      {
        "name": "Caliber",
        "value": "9mm Luger"
      },
      {
        "name": "Capacity",
        "value": "15"
      }
    ],
    "created_at": "2026-01-14T18:22:05+00:00",
    "updated_at": "2026-08-02T09:41:16+00:00"
  }
}
404No product with that `product_id` belongs to your team. Note the error/message envelope — auth, ability and rate-limit failures instead return a bare message.
{
  "error": "not_found",
  "message": "Product not found."
}
401Missing, malformed or revoked bearer token. Requires Accept: application/json, or the API will try to redirect instead.
{
  "message": "Unauthenticated."
}
Navigation

Type to search…

↑↓ navigate↵ selectEsc close