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
/v1/products- 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
- 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.
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{
"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
}
}{
"message": "Invalid ability provided."
}{
"message": "Too Many Attempts."
}Retrieve a product
/v1/products/{product_id}- 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
- 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.
curl https://api.firearmcart.com/v1/products/501 \
-H "Authorization: Bearer $FIREARMCART_TOKEN" \
-H "Accept: application/json"{
"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"
}
}{
"error": "not_found",
"message": "Product not found."
}{
"message": "Unauthenticated."
}
