Chapter03
Reference · Store API v1
Store API overview
Read your store, its products at your prices, the category tree, what changed and your subscription, have changes sent to your own URL, change your pricing rules, pricing settings and restrictions, and connect your distributors, from your own systems: a point of sale, your own website or a script.
3.1What it reads
The Store API reads what the FFL Monkey plugin puts in a store, for a store that is not on WordPress or a system beside one: your products with your own prices, stock and availability, the category tree they are filed in, and what changed since you last asked. Where these pages say your FFL Monkey screens, such as your Pricing or Exclusions screen, they mean your store's settings: in wp-admin under FFLMonkey.com, or, for a store on its own website, the licence's settings in your FFL Monkey account. It also says what state your store is in, what it still has to set up, and how its FFL Monkey subscription stands.
- Your prices, as your plugin would show them. Each product comes with the price your pricing rules and rounding give it, the same rows and the same prices the plugin reads.
- It reads, and changes how your store is priced and what it shows: your pricing rules and restrictions, your store-wide pricing settings, the distributors you buy from, and the URL your store's changes are sent to, its webhook. To change your in-house stock and prices, use the Inventory API. It is the same API, with the same key, the same rules and the same errors, under
https://backend.fflmonkey.com/api/v1/inventory.
- For an FFL Checkout store, it finds the licensed dealers a firearm may be sent to and records which one a buyer chose: FFL dealers and FFL orders.
- One key, one store. A key reaches its own store and nothing else, and does only what its permissions allow.
3.2Authentication
Send the key in the Authorization header of every request:
Authorization: Bearer ffl_sk_live_3f9a…
Keys start with ffl_sk_live_ followed by 49 letters and digits, and are made in your FFL Monkey account, on its API keys page. Create a key.
Keep the key on a server
It is a secret. Use it from your own server or a script, and never in a web page or browser code. The API does not answer browsers' cross-origin requests, on purpose.
A missing, mistyped, unknown or revoked key gets 401 invalid_api_key. If your FFL Monkey subscription is suspended, cancelled or not yet active, the endpoints that read or change your store's data answer 403 license_not_active, with the reason. GET /key and GET /store still answer, and say why, and GET /subscription answers with a link to pay from.
3.3Permissions
A key has a permission per area of your store, chosen when you create it in your FFL Monkey account, under API keys: No access, Read, or, where there is something to change, Read and write. A key belongs to one store, and is offered the permissions of that store's product. Today a key can be given:
| Permission | Levels | What it opens |
| Products and changes | No access, Read | Your products at your prices, the category tree, the brands and product classes, and what changed: GET /products, GET /products/{id}, GET /categories, GET /manufacturers, GET /product-classes and GET /changes. |
| Pricing rules | No access, Read, Read and write | For Data Feed stores. Read: GET /pricing-rules, GET /pricing-rules/{id} and the store-wide GET /pricing-settings. Read and write also creates, changes, deletes, reorders and previews rules, and changes the settings with PATCH /pricing-settings. In GET /changes it adds pricing_rule.* and pricing_settings.*. |
| Restrictions | No access, Read, Read and write | For Data Feed stores. Read: GET /restrictions and GET /restrictions/{id}. Read and write also creates, changes, deletes and previews them. In GET /changes it adds restriction.*. |
| Distributors | No access, Read, Read and write | For Data Feed stores. Read: GET /distributors, which distributors you buy from and whether your credentials were accepted, never the credentials themselves. Read and write also connects and disconnects them: PUT /distributors/{slug} and DELETE /distributors/{slug}. Read also works out how to buy an order from them: POST /sourcing-plan, which shows what each charges you. In GET /changes it adds distributor_account.* and license.distributor_selection.*. |
| In-house inventory | No access, Read, Read and write | The Inventory API. Read lists and reads rows, Read and write also changes them. In GET /changes it adds the changes to your in-house rows. |
| Webhook | No access, Read, Read and write | For Data Feed and FFL Checkout stores. Read: GET /webhook. Read and write also sets, tests and removes it: PUT /webhook, POST /webhook/test and DELETE /webhook. A key with Read and write decides where all your store's changes are sent, so give it only to a system you trust with them. |
| FFL dealers | No access, Read | For FFL Checkout stores. The licensed dealers a firearm may be sent to: GET /dealers/search and GET /dealers/{licenseNumber}. |
| FFL orders | No access, Read, Read and write | For FFL Checkout stores. Read: GET /ffl-orders/{ref}. Read and write also reports the dealer a buyer chose, the transfer as you run it, and that it happened: POST /ffl-orders, POST /ffl-orders/{ref}/transfer and POST /ffl-orders/{ref}/complete; and uploads the licence of a dealer the registry does not have, POST /ffl-contributions, read back with GET /ffl-contributions/{contribution}. Their changes, ffl_selection.* and ffl_order.*, are sent to your webhook: GET /changes needs Products and changes, which an FFL Checkout store's key cannot be given, so it is refused (403 product_not_included) and a webhook is the only way to be told. |
| Subscription | No access, Read | For Data Feed and FFL Checkout stores. Your FFL Monkey subscription, and a link to pay from when it will not renew: GET /subscription. With Products and changes as well, which only a Data Feed store's key can hold, GET /changes also carries your licence's status changes and subscription.ending_soon; an FFL Checkout store gets them by webhook. |
A key's permissions are also listed as scopes (products:read, pricing_rules:read, pricing_rules:write, restrictions:read, restrictions:write, distributors:read, distributors:write, inventory:read, inventory:write, webhook:read, webhook:write, dealers:read, ffl_orders:read, ffl_orders:write, subscription:read). An endpoint that needs one the key does not hold answers 403 insufficient_scope, naming it in required_scope; an endpoint of the other product answers 403 product_not_included, naming that product in required_product. GET /key and GET /store need no permission: any key may call them. More permissions are offered as their endpoints are added.
3.4Base URL and formats
https://backend.fflmonkey.com/api/v1
- Responses are always JSON, with the result in
data. A list also has meta.
- Times are ISO 8601, in UTC:
2026-10-08T14:03:00+00:00.
- Send request bodies as JSON with
Content-Type: application/json.
- Money comes back as a string with two decimals (
"499.00"), never a number. Weights and sizes are decimal strings too. Send money as a string too ("22.50"), with at most two decimals: a JSON number is read the same, and a third decimal is refused rather than rounded.
- A product is known by its
product_id, a whole number. Changes name products by it.
3.5Rate limit
Each key may make 120 requests a minute, counted across the whole API, Inventory API included. Every response to a valid key carries X-RateLimit-Limit and X-RateLimit-Remaining, refusals included, except a 404 or 405 for an address or a method the API does not have, which is answered before it is counted. Past the limit you get 429 rate_limited with a Retry-After header giving the seconds to wait.
A page of products can hold up to 500, so walking even a large catalog takes a handful of requests.
POST /webhook/test has a second limit of its own: 5 a minute for the store, whichever key calls it, because each call makes FFL Monkey send a request to your URL.
POST /pricing-rules/preview and POST /restrictions/preview share one too: 10 previews a minute for the store, the two together, whichever key calls them, because each one works part of your catalog out twice, for up to five seconds.
PUT /distributors/{slug} has its own as well: 5 a minute and 20 an hour for the store, whichever key calls it, refused ones included, because each one signs in at the distributor with your credentials, and a distributor may lock an account after repeated failed sign-ins. Disconnecting is not limited.
Each of these limits counts every request the endpoint answers, refused ones included, and on these endpoints X-RateLimit-Limit and X-RateLimit-Remaining describe whichever limit has fewer requests left, usually the endpoint's own rather than the key's 120.
3.6Errors
Every error has the same shape as the Inventory API's. Branch on error; show message to a person. New codes may be added, so treat an unknown error by its HTTP status. These codes are the Store API's own:
3.7The product
Every product the Store API answers with has this shape: the catalog's description of it, and your own price, stock and availability.
| Field | Type | What it holds |
| product_id | integer | The product's id. Changes name it, and GET /products/{id} takes it. |
| upc | string | The product's UPC. |
| sku | string | Such as FFL-764503026751. |
| title | string | The product's name. |
| description | string or null | HTML, already cleaned, safe to insert into a page. |
| resume | string or null | A one-line summary of the specifications. |
| manufacturer | string or null | The manufacturer's name. |
| category | string or null | The slug of the product's category, from GET /categories. |
| category_path | list of strings | The slug of every category from the root down to the product's own. It follows the tree, so never split a slug on its dots to get it. |
| product_class | string or null | Such as firearms. |
| attributes | object | Name to value, such as "caliber": "9mm". |
| images | list | One entry per photograph, the primary first, each with every size it comes in under variants. A product with no photograph of its own has one shared placeholder entry, with placeholder true. |
| weight | string or null | Pounds, as a decimal string. |
| dimensions | object | length, width and height in inches, as decimal strings, each null when unknown. |
| requires_ffl | boolean | Must ship to a licensed dealer. true for a product with no class: unknown means require it. |
| requires_sot | boolean | Must ship to a dealer with special occupational tax (SOT) status. |
| is_ammunition | boolean | Whether it is ammunition. |
| price | string or null | Your price, with your pricing rules and rounding applied. Null when no offer can be sourced, and then is_visible is false. |
| stock | integer | How many are in stock to sell. |
| sourced_from | string or null | The distributor the price comes from, in_house for your own stock, or null. |
| is_visible | boolean | Whether you sell it now. false means take it off sale. |
| cost_price | string or null | The cost on the offer the price comes from. For your own records. |
| map_price | string or null | The minimum advertised price on that offer, if it has one. |
| msrp_price | string or null | The manufacturer's suggested price on that offer, if it has one. |
3.8Keep a copy of your catalog current
For a website or a point of sale that keeps its own copy of your products:
- Read every product you sell with GET /products, a page at a time, and keep the
meta.changes_cursor of the first page.
- Call GET /changes with that cursor. Apply each change, then save
meta.next_cursor and call again from it. Every answer has one, even when nothing changed.
- When
meta.has_more is true, call again straight away; otherwise, a few minutes later.
A change is applied as it comes: its payload is the state after the change, never a difference, and applying one twice is harmless. Changes are kept for at least 30 days. A cursor older than that answers 410 cursor_expired: start again from step 1.
3.9When catalog sync is switched off
A store can switch catalog sync off in its FFL Monkey screens. While it is off, GET /products and GET /products/{id} answer 409 sync_paused instead of a page: it is not an empty catalog, so keep what you have. GET /categories still answers, and GET /changes leaves out product, price and in-house inventory changes, which are not replayed later. Once GET /products answers again, read your products again.
3.10Have changes sent to you
Instead of asking GET /changes every few minutes, a store can have each change sent to its own URL as it happens. A store has one webhook URL, set with PUT /webhook; a store whose Data Feed plugin is connected already has its webhook. Each delivery is signed, so your receiver can check it came from FFL Monkey. How webhooks work, and how to check the signature.
3.11Change how your store is priced and what it shows
A Data Feed store's pricing rules and restrictions, the ones on its Pricing and Exclusions screens, can be read and written with the API, with the same checks and the same consequences as a save on those screens. A rule or restriction names categories, brands and product classes by the slugs GET /categories, GET /manufacturers and GET /product-classes list. Your store-wide pricing settings go with them: the default margin, for a product no rule matches, and the price rounding and MAP floor that finish every price. PATCH /pricing-settings changes them. How rules and restrictions work, and the saves you confirm.
3.12Connect your distributors
A Data Feed store's distributors, the ones on its distributors screen, can be listed, connected and disconnected with the API. GET /distributors lists every distributor FFL Monkey offers, whether your store buys from it, and in credential_fields what it takes to connect it.
- Credentials are checked with the distributor before anything is saved. PUT /distributors/{slug} signs in at the distributor with them, and only if it accepts them saves them and switches it on. A refused check (
422 credentials_rejected) or one the distributor does not answer (502 distributor_unreachable) saves nothing.
- They are never returned, in any answer, change or webhook: only whether they were accepted, and when.
- Turning one off forgets its credentials. DELETE /distributors/{slug} switches it off and deletes them.
- 5 connects a minute and 20 an hour per store, because a distributor may lock an account after repeated failed sign-ins.
- Buying an order from them. POST /sourcing-plan takes an order's lines and where each goes, and answers every distributor carrying each line and the cheapest way to buy the lot with shipping, the plan your Data Feed order screen shows.
3.13FFL Checkout: dealers and orders
An FFL Checkout store that sells firearms on a website of its own can do what the FFL Checkout plugin does at checkout: find the licensed dealer (an FFL) a firearm ships to, and record which one the buyer chose.
- Find a dealer. GET /dealers/search is the search FFL Monkey's own dealer picker runs: by a ZIP or a point, nearest first, or by name or licence number. It finds only active dealers a firearm may be transferred to, and gives each one's coordinates for your own map.
- Check one licence. GET /dealers/{licenseNumber} says whether a licence is real and current, in any form of its number. Keep a dealer's
license_key: it stays the same when the licence is renewed.
- Report the order. POST /ffl-orders records the dealer the buyer chose, by your own order number, and POST /ffl-orders/{ref}/complete that the transfer happened, which puts the dealer on file with your store. Both are safe to repeat. These two keep the dealer, never the buyer.
- Report the transfer. POST /ffl-orders/{ref}/transfer keeps the transfer as your system runs it, from the request to the dealer to the parcel received, sent whole after every change, so it shows in your FFL Monkey screens beside the order. It keeps the buyer's details you send in it.
- A dealer the registry does not have. Send the licence the buyer gave you with POST /ffl-contributions; FFL Monkey reads and approves it, and GET /ffl-contributions/{contribution} then answers the dealer's licence number.
- Dealer searches share your checkout's limits: 120 a minute from one address and 5,000 a day for the store, counted with your store's dealer picker. A lookup of one dealer by licence number is outside the daily limit.
3.14Endpoints
Every endpoint has a page with its fields and a documented example.
3.15Versioning
This is version 1, and the /v1/ in the address is a promise: it only grows. We may add endpoints, response fields, optional request fields, error codes, permissions and values (such as a new setup step or a new kind of change), so ignore fields you do not know and treat unknown values as valid.
We will not remove or rename a field, change its type or meaning, or make an optional field required. A change that would break that is a new version at a new address.
3.16OpenAPI document
A machine-readable description of the Store API (OpenAPI 3.1), for Postman, code generators and similar tools:
https://docs.fflmonkey.com/openapi/v1.json
The Inventory API has its own, linked from its overview.