# FFL Cockpit API — Agent Reference

> REST API for building firearm-industry storefronts/integrations on top of the
> FFL Cockpit distributor network: distributor configuration, product data (bulk via
> S3), restriction checks, cost-optimized fulfillment quotes, real order
> placement/tracking, and crowdsourced fraud tools.

This file is the complete integration reference, optimized for AI coding
agents. Machine-readable spec: `GET /v1/openapi.json` (OpenAPI 3.0, includes
per-distributor config schemas). Human docs: `GET /v1/docs/guide`.

## Basics

- Base URL: `https://api.fflcockpit.com`
- Auth: header `x-api-key: <API_KEY>` on every endpoint except `/v1/openapi.json`, `/v1/docs`, `/v1/docs/guide`, `/v1/llms.txt`. Keys ALSO require the caller IP to be on the key's registered allowlist (mandatory; managed via support, visible at `GET /v1/account`): unregistered key -> `403 ip_whitelist_required`, wrong IP -> `403 ip_not_allowed`. Calls only work from the subscriber's registered servers.
- All requests/responses are JSON. Responses >50KB are gzipped when you send `Accept-Encoding: gzip`.
- Success envelope: `{"success": true, "data": <payload>}`
- Error envelope: `{"success": false, "error": {"code": "<machine_code>", "message": "<human text>", "details": <optional structured data>}}`
- HTTP codes: 400 invalid input, 401 missing key, 403 key not entitled/IP not allowlisted, 404 unknown route/resource, 405 wrong method, 409 conflicts with current state (`no_active_distributors`, `no_validated_distributors`, `duplicate_order`, `validation_failed`, `fulfillment_not_configured`), 413 body >1MB, 429 rate limited (honor `Retry-After`), 500 retry with backoff, 503 dependency unavailable (retry shortly).
- Rate limits (per key): standard 60/min + 10,000/day; pro 300/min + 100,000/day; enterprise 1,000/min + 1,000,000/day. Check `GET /v1/usage`.
- Verify a key: `GET /v1/ping` -> `{"pong": true, "key_name": "...", "tier": "standard"}`. If routing through a static-egress proxy, pass it per-request (curl `-x http://user:pass@proxy-host:port`, Python requests `proxies=`, Node undici `ProxyAgent`) - the PROXY egress IP is what must be whitelisted.

## Critical invariants (read before writing code)

1. **Config is one document, replaced whole.** `PUT /v1/config` replaces the ENTIRE configuration. Always `GET /v1/config` fresh, modify, then PUT back. Pushing a stale copy reverts other changes.
2. **Schema validation happens server-side before save.** Invalid documents return `400 schema_validation_failed` with `error.details = [{"path": "...", "message": "..."}]` and nothing is saved. The schema is at `GET /v1/config/schema` (JSON Schema draft-04, served raw).
3. **Masked secrets round-trip.** Credential values read back as `"********"` may be PUT back unchanged — stored secrets are preserved. Send real values only when setting/changing a credential. Never treat `*`-runs as real values.
4. **Product endpoints serve YOUR restricted catalog.** All product reads (search/detail/facets/catalog) come from the S3 catalog feed for the key's ACTIVE (`distributors.<Name>.active == true`) and credential-VALIDATED distributors (validation is recorded by `POST /v1/config/validate` - run it after setting credentials; none validated -> `409 no_validated_distributors`) with the subscriber's product restrictions, cost filters, and custom catalog pricing ALREADY APPLIED - the same data website sync lists. Data refreshes ~every 20 minutes. No active distributors -> `409 no_active_distributors`; feed temporarily unpublished -> `503 feed_unavailable` (retry shortly).
5. **Bulk product data comes from S3, not the REST API.** `GET /v1/products/catalog` returns ONE presigned parquet URL containing the subscriber's complete restricted catalog. URLs expire (default 900s): download immediately, never persist URLs, re-poll each cycle and skip when `version` is unchanged. First call after a feed republish or config change may take ~60s while the export builds.
6. **`POST /v1/orders` places REAL purchase orders with distributors.** There is no sandbox. `order_id` is the idempotency key: reusing one that already produced distributor orders returns `409 duplicate_order` (safe to retry after network failures). Placement volume is independently capped at 60/hour and 500/day per key (`429 order_volume_limit`).
6b. **The API key is a financial credential — server-side only.** Never embed it in browser JS, mobile apps, or repositories; store it in a secrets manager/env var. Request bodies are capped at 1MB (413). Authenticated responses carry `Cache-Control: no-store`.
7. **Fraud reports must NEVER be filed for orders shipped to an FFL dealer** (`shipping_is_ffl: true` is rejected server-side). Fraud caps per key/24h: 1000 checks, 25 reports, 25 removals.
8. **Prices are dealer costs.** `unit_price` is the subscriber's distributor cost; retail pricing must respect `map_price` where present.

## Endpoints

### Account
- `GET /v1/ping` — key check.
- `GET /v1/account` — `{key_name, contact_email, tier, ip_whitelist:[registered server IPs], limits:{per_minute,per_day}, configured_distributors:[names], active_distributors:[names]}`
- `GET /v1/usage` — `{tier, limits, today, this_month, by_endpoint_today:[{endpoint,requests}], by_day_last_7:[{day,requests}]}`

### Distributor registry
- `GET /v1/distributors` — map of distributor name -> `{distid, automated_fulfillment, config_schema, default_config}`.
  - `config_schema` fields: `{<field>: {type: string|number|boolean|array|state-selector, label, helperText?, config_key}}`.
  - `config_key` is a dash-delimited path into the distributor's config entry: `"fulfillment-billing_address-city"` means the value lives at `distributors.<Name>.fulfillment.billing_address.city` in the configuration document.

### Configuration (en-masse, like the Cockpit WordPress plugin)
- `GET /v1/config` -> `{"configuration": {...}}` (secrets masked).
- `GET /v1/config/schema` -> raw JSON Schema draft-04 (no envelope).
- `PUT /v1/config` body = the full document, optionally wrapped: `{"configuration": {...}}`. Query `?validate=true` additionally rejects saves that would delete more live catalog than the subscriber's delete threshold (`409 validation_failed`). Response: `{"saved": true, "configuration": {...masked...}}`.
- `POST /v1/config/validate`:
  - empty body -> validates the SAVED config: `{valid, schema_errors:[{path,message}], configured_distributors, active_distributors, credentials:[{distid,distributor,valid}], warnings:[...]}`. Contacts each active distributor's API live — slow (seconds per distributor); call after config changes only.
  - body `{"configuration": {...}}` -> dry-run schema check, nothing saved: `{dry_run: true, schema_valid, errors:[{path,message}]}`.

Config document required top-level sections (per schema): `distributors`, `targets`, `notification_email`, `pricing`, `product_restrictions`. A distributor entry looks like:
```json
"distributors": {
  "AmChar": {"distid": "AMC", "active": true, "fulfillment": {"api_key": "dealer-key"}}
}
```

### Products
- `GET /v1/products/search` — interactive search over the restricted catalog, offset-paged (max `page_size` 100, max depth 10k rows). Query params: `q` (free text; 8+ digit numeric = exact UPC), `upc`, `sku`, `distributor` (distid), `manufacturer`, `product_class`, `category`, `min_price`, `max_price`, `in_stock`, `drop_ship_only`, `ffl_required`, `ignore_restrictions` (true = skip your product restrictions/cost filters; custom pricing + validated-distributor gate still apply; also accepted on detail/facets/catalog), `sort` (`price|-price|name|-name|qty|-qty`), `page`, `page_size`.
  Response: `{products: [Product], page, page_size, total}` where Product = `{distid, distsku, stockid, upc, name, mfg_name, mpn, product_class, item_cat, item_gb_cat (numeric category id, matches analytics category_id), item_gb_cat_name, ffl_req, unit_price (custom pricing applied), shipping_cost, total_cost, map_price, msrp, qty_on_hand, drop_ship_flg, images:[{src}], product_url}` (search omits description fields; detail/export carry them).
- `GET /v1/products/catalog` — the subscriber's COMPLETE restricted catalog as ONE parquet file on S3. Params: `expires_in` (60-3600s, default 900), `include_attributes` (bool).
  Response: `{version, format: "parquet", compression: "snappy", rows, restrictions_applied: true, custom_pricing_applied: true, expires_in_seconds, url, attributes_feed?: {format: "csv.gz", url}}`. Columns match Product + `short_description`/`description`/`product_class_description`. Slice per distributor client-side on the `distid` column.
- `GET /v1/products/facets` — `{manufacturers: [...], categories: [...], product_classes: [...]}` (sorted distinct values across your restricted catalog) for building search filters/faceted nav; values feed the matching search params. Cache client-side, refresh daily.
- `GET /v1/products/{upc}` — UPC = 8-14 digits. Response: `{upc, offers: [Product incl. short_description/description]}` — full record; `ffl_req` feeds straight into order items[].ffl_req. 404 if not in the restricted catalog.

### Analytics (one metric per call; daily-refreshed rollups, scoped to active distributors, shared cache - poll a few times/day max)
- `GET /v1/analytics/{metric}` where metric is one of: `top-products` (movement leaders; scope with `product_class`/`category_id`), `on-sale` (price drops), `margin-opportunities` (`sort_by=gross_profit|margin_pct`, `min_confidence`), `trending` (7d acceleration vs prior 23d; tunables `min_7d_units`, `min_30d_units`), `new-arrivals` (first seen in window), `newly-available` (restocked from zero), `price-gaps` (one distributor far below peer average).
  Common params: `window` (`7d|30d|60d|90d`, default 7d), `limit` (<=200, default 50), `product_class` (csv of 2-letter codes: AC=Accessories, AG=Air Guns, AO=Ammunition, AP=Apparel, AR=Archery, BP=Black Powder, BS=Binoculars/Spotting, FA=Firearms, FI=Fishing, FP=Firearm Parts, HS=Holsters, HT=Hunting, HZ=Hazardous, KN=Knives, LL=Lights/Lasers, MG=Magazines, MZ=Muzzleloading, OP=Optics, OT=Other, RC=Range Bags/Cases, RL=Reloading, SF=Safes, SO=SOT), `category_id` (csv of numeric ids - discover from any analytics row's `category_id`/`category_name` or product data's `item_gb_cat`/`item_gb_cat_name`, same taxonomy).
  Examples: `/v1/analytics/trending?product_class=FA&window=7d` (firearms only); `/v1/analytics/top-products?product_class=AO,MG&window=30d&limit=100`; `/v1/analytics/margin-opportunities?category_id=981,982&sort_by=margin_pct`.
  Response: `{metric, as_of, window_days, count, results: [rows with upc, name, mfg_name, category_id, category_name + metric measures]}`. Unknown metric -> 404 listing valid slugs.

### FFL dealer directory (checkout transfer-dealer picker)
- `GET /v1/ffls?zip=37201&radius=15&name=` — dealers near the customer's ZIP. Params: `zip` (required, 5 digits), `radius` (1-50 miles, default 15), `name` (optional substring filter, <=60 chars, charset `[A-Za-z0-9 '&.-]`). Max 150 results, ordered best-first: license-on-file dealers in the exact ZIP, on-file nearby, then remaining local/nearby.
  Response: `{count, ffls: [{license_number, list_name, business_name, license_name, premise_street, premise_city, premise_state, premise_zip_code, voice_phone, email, lat, lng, ffl_on_file, sot_on_file, expiration_date, active}]}`. `ffl_on_file: 1` = license already on file with FFL Cockpit (fastest transfers). `sot_on_file: 1` = NFA-capable.
- `GET /v1/ffls/{license_number}` — validate one FFL and get the FULL record (adds `transfer_fee_longgun`, `transfer_fee_handgun`, `residential`, `contact_required`, mailing address). License: 10-25 chars of digits/letters/dashes; masked segments supported with `X` (e.g. `1-23-XXX-XX-XX-12345`). 404 if unknown.
  Flow: customer picks a dealer -> validate via this endpoint -> pass `license_number` into `POST /v1/orders` as `ffl.licenseNumber`.

### Restrictions
- `POST /v1/restrictions/check` body: `{"shipping": {"state","city"?,"county"?,"zip"?,"country"?="US"}, "items": [{"upc"?,"sku"?,"distid"?,"product_class"?,"category"?,"attributes"?}], "target"?: null}` (max 100 items).
  Response: `{approved: bool, items: [{upc, sku, approved, blocking_rules: [...], warnings: [...], manual_review: [...]}]}` — each matched rule carries `customer_message`. Call at checkout time; a `BLOCK` match means do not sell to that destination.

### Orders
- `POST /v1/orders/quote` body: `{"items": [{"upc": "...", "qty": 1}]}` (max 100).
  Response: `{fulfillable, plan: {orders: [{distid, distributor, items: [{upc, distsku, qty, unit_price, line_total}], product_subtotal, shipping_estimate}], total_estimate, note}, items: [{upc, qty, best: Option, alternatives: [Option x5]}], unfulfillable: [{upc, qty, reason}]}` where Option = `{distid, distributor, distsku, qty_on_hand, unit_price, shipping_cost, map_price, msrp, drop_ship_flg, line_total}`. Applies the subscriber's cost adjustments, custom shipping model, and restrictions. Line cost = unit_price*qty + estimated shipping.
- `POST /v1/orders` body:
```json
{"order_id": "web-10021",
 "customer": {"name": "Jane Sample", "email": "jane@example.com", "phone": "555-555-0100",
              "address1": "1 Main St", "address2": "", "city": "Nashville", "state": "TN", "zip": "37201"},
 "items": [{"distid": "12", "distsku": "GLOCK19GEN5", "upc": "764503037276", "qty": 1, "ffl_req": true}],
 "ffl": {"licenseNumber": "1-23-456-78-9A-12345"},
 "ship_to_store": false,
 "po_number": "optional"}
```
  Field reference:
  - `order_id` (required): 1-50 chars `[A-Za-z0-9_-]`, unique per account (idempotency key; reuse -> `409 duplicate_order`).
  - `customer` (required): `name`, `email`, `address1`, `city`, `state` (2-letter), `zip` required; `phone` (recommended — some distributors require it) and `address2` optional.
  - `items` (required, 1-100): each needs `distid` + `distsku` + `upc` + `qty>=1` (take them from a quote's `best`); `ffl_req` (bool, default false) marks firearm/regulated items that must ship to an FFL.
  - `ffl.licenseNumber` (conditional): destination dealer from `GET /v1/ffls` — required when any item has `ffl_req: true` and `ship_to_store` is false.
  - `ship_to_store` (bool, default false): route the WHOLE order to the configured store.
  - `po_number` (optional): passed through to the distributor order. When omitted, the engine generates one PER distributor order: `{order_id}-{first2+last2 chars of api_key, uppercased}-{f}{m}{s}` (f=1 ffl-required order, m=1 always for API orders, s=1 ship-to-store) — e.g. `web-10021-ALOM-110`, so a split cart's halves get distinct POs. When supplied, YOUR value is applied to EVERY distributor order in the split (a per-PO lookup then returns multiple records). Some distributors echo a normalized PO back; the fulfillment record's `po_number` is the source of truth.

  **Order types** (decided by `ship_to_store` + `ffl` + per-item `ffl_req`):
  1. **Drop-ship to customer**: `ship_to_store: false`, item `ffl_req: false` -> ships directly to the customer's address. The chosen product must be drop-shippable (`drop_ship_flg: 1`).
  2. **FFL transfer**: item `ffl_req: true` + `ffl.licenseNumber` -> ships to the licensed dealer; customer picks up there (ID/background check at the dealer).
  3. **Ship-to-store**: `ship_to_store: true` -> everything routes to your store; FFL items use the store FFL configured at `fulfillment.ship_to_store.ffl` in your configuration (`ffl` may be omitted). In-store pickup.

  **Automatic splitting**: mixed carts become one distributor order per (distributor, ffl_req-group) — e.g. a Glock (`ffl_req: true`) + optic (`ffl_req: false`) from the same distributor produces TWO distributor orders, one to the FFL, one to the customer. With `ship_to_store: true` items group per distributor only. Inspect the split in the response's `fulfillment_orders`.

  Response: engine status + `{order_id, fulfillment_orders: [FulfillmentOrder]}`.
- `GET /v1/orders` — history. Params: `limit` (<=100), `offset`, `sort` (`order_date|order_id|ship_date|order_status|order_source`), `direction` (`asc|desc`), `search`. Response: `{count, orders: [FulfillmentOrder]}`.
- `GET /v1/orders/{order_id}` -> `{order_id, fulfillment_orders: [FulfillmentOrder]}`; 404 if none.
- `GET /v1/fulfillment/history` — query fulfillment/tracking records. Every API-placed order lands in this table at creation (order_source `cockpit_cart`); monitors update shipping fields in place. Params (all optional, ANDed): `reference` (matches order_id OR po_number OR distributor_order_id OR tracking number), `order_id`, `po_number`, `distributor_order_id`, `tracking_number`, `distid`, `order_source`, `order_status`, `ship_status`, `from`/`to` (YYYY-MM-DD, inclusive), `limit` (<=100, default 25), `offset`, `include_errors` (default true).
  Response: `{count, orders: [FulfillmentOrder], errors: [{failure_timestamp, order_source, order_id, po_number, distid, failure_reason}]}` — `errors` holds failed placement attempts so orders that never produced a distributor order remain traceable. Use this to answer "where is PO X / order Y?": `GET /v1/fulfillment/history?po_number=X` or `?reference=Y` returns the matching records with `ship_tracking_number` + `ship_tracking_url`.
  FulfillmentOrder = `{order_date, order_id, distid, distributor_order_id, ffl, customer_name, order_status, order_source, po_number, ship_date, ship_company, ship_method, ship_status, ship_tracking_number, ship_tracking_url, ship_to_store, ffl_required, dist_order_items(JSON string)}`.

### Fraud (crowdsourced, shared across the dealer network)
Signal fields (used by all three endpoints): `shipping_name, shipping_address_1, shipping_address_2, shipping_city, shipping_state, shipping_postcode, shipping_country, shipping_is_ffl, billing_name, billing_address_1..., email, phone, ip_address, order_items:[{upc,sku,name,qty,price}]`.
- `POST /v1/fraud/check` — requires >=1 strong signal (`shipping_address_1`, `billing_address_1`, `email`, `phone`). Matching is like-for-like only (shipping vs shipping, billing vs billing); name/IP/items only corroborate. Response: `{fraud: {matched, matched_on:[signals], report_count, names_used, emails_used, phones_used, ips_used, addresses_used, billing_used, items_used, items_overlap, items_similar, reported_by_you, first_reported, last_reported, notes}}`. Treat `matched: true` as "hold for manual review", not auto-cancel.
- `POST /v1/fraud/reports` — report a CONFIRMED fraudulent order (chargeback/stolen card). Requires `shipping_address_1`; rejected if `shipping_is_ffl`. Optional: `order_source`, `order_id`, `order_json` (raw order), `notes`. Response includes refreshed `fraud` summary.
- `DELETE /v1/fraud/reports` — removes only YOUR report matching the signals; other dealers' reports remain.

## Workflows

### 1. Initial setup
```
GET /v1/distributors                  # find names, distids, config_schema field contracts
GET /v1/config                        # fetch current document
# edit: add/activate entries under .distributors, placing credential fields
#       at their config_schema config_key paths
POST /v1/config/validate  {"configuration": <edited>}   # dry-run schema check
PUT  /v1/config           {"configuration": <edited>}   # save (400 details on schema errors)
POST /v1/config/validate                                 # live credential check
# NOTE: product endpoints only serve distributors whose credentials PASSED this
# validation - run it (successfully) before expecting products.
```

### 2. Catalog sync loop (run every ~20 min; S3, not REST pagination)
```python
import pandas as pd, requests
API, H = "https://api.fflcockpit.com/v1", {"x-api-key": KEY}
feed = requests.get(f"{API}/products/catalog", headers=H).json()["data"]
if feed["version"] != last_seen_version:          # new snapshot?
    catalog = pd.read_parquet(feed["url"])        # download NOW; the URL expires
    # restrictions + custom pricing are already applied server-side
    # upsert into your DB keyed on (distid, stockid); diff against stored copy for price/qty deltas
    last_seen_version = feed["version"]
```

### 3. Checkout guard
```
GET  /v1/products/{upc}          # live availability/cost across your distributors
POST /v1/restrictions/check      # destination legality; block on any BLOCK rule
GET  /v1/ffls?zip=&radius=       # firearm in cart? show FFL picker near customer's ZIP
GET  /v1/ffls/{license_number}   # validate the chosen dealer (active? on file? fees?)
POST /v1/fraud/check             # hold order for review when matched
```

### 4. Fulfillment
```
POST /v1/orders/quote            # items -> cheapest per-item source + per-distributor plan
POST /v1/orders                  # place, using quote's distid/distsku; unique order_id;
                                 #   ffl.licenseNumber for ffl_req items (or ship_to_store)
GET  /v1/orders/{order_id}       # poll status/tracking every 15-30 min (not per page view)
GET  /v1/fulfillment/history?reference=<po-or-order-or-tracking>   # tracking lookup
```

### 5. Fraud lifecycle
```
POST   /v1/fraud/check           # at checkout
POST   /v1/fraud/reports         # after confirmed fraud (never for FFL-destination orders)
DELETE /v1/fraud/reports         # if your report was a false alarm
```

## Pitfalls checklist for generated code

- [ ] Ensure ALL API traffic egresses from the subscriber's whitelisted IPs — requests from any other address fail with 403. If the runtime has no static IP: VPS/EC2 hosts already have one; AWS Lambda/Fargate -> put in a VPC with a NAT Gateway + Elastic IP (GCP Cloud NAT / Azure NAT Gateway); Heroku/Render/Fly -> platform static-IP feature or QuotaGuard/Fixie add-on; Vercel/Netlify/Workers -> route API calls through a small forward proxy (e.g. Squid with auth, CONNECT-only to api.fflcockpit.com) on a static-IP VPS and set `HTTPS_PROXY` (Python requests `proxies=`, Node undici `ProxyAgent`). CONNECT tunneling keeps TLS end-to-end so the proxy never sees the key. Full recipes: /v1/docs/guide#static-egress.
- [ ] Re-fetch `/v1/config` immediately before every PUT (full-document replace).
- [ ] Preserve masked `********` secret values verbatim on config round-trips.
- [ ] Download catalog parquet files immediately; never store presigned URLs or assume a version persists >1h.
- [ ] Generate a unique, stable `order_id` per checkout and rely on `409 duplicate_order` for retry safety.
- [ ] Gate firearm sales on `/v1/restrictions/check` BLOCK results and collect an FFL (via `/v1/ffls`) for `ffl_req` items.
- [ ] Set `ffl_req: true` on every firearm/regulated line item — it drives FFL routing and order splitting.
- [ ] Run `POST /v1/config/validate` after credential changes — product endpoints only serve validated distributors (`409 no_validated_distributors` until then).
- [ ] Back off on 429 using `Retry-After`; budget ~5 requests per full catalog sync, not per-product calls.
- [ ] Use `/v1/products/search` for UI queries only (depth-capped); bulk = `/v1/products/catalog`.
- [ ] Respect `map_price` when setting retail prices.

Support: support@garidium.com
