# GunSpec API

Guidance for consuming the GunSpec.io REST API: a firearms specification database
(firearms, ammunition, calibers, manufacturers, countries, and game-balance
stats). Merge this section into your project `AGENTS.md` (repo root) or
`~/.codex/AGENTS.md` for all projects.

## Discovery

- Base URL: `https://api.gunspec.io/v1`
- OpenAPI 3 spec: `https://api.gunspec.io/openapi.json`, the source of truth. Read it for any endpoint or field not listed here. Do not invent routes or fields.
- Docs: `https://docs.gunspec.io`

## Reference docs (link these for detail)

- Field reference / data dictionary: `https://docs.gunspec.io/en?section=field-reference`
- Error handling: `https://docs.gunspec.io/en?section=errors`
- Rate limits: `https://docs.gunspec.io/en?section=rate-limits`
- Caching:     `https://docs.gunspec.io/en?section=caching`
- Plans and pricing: `https://docs.gunspec.io/en?section=rapidapi-plans`
- Endpoint access by tier: `https://docs.gunspec.io/en?section=endpoint-access`
- Auth and quick start: `https://docs.gunspec.io/en?section=getting-started`
- Pagination (rules, per-plan depth, every paged endpoint): `https://docs.gunspec.io/en?section=pagination`
- Versioning (what changes without a bump, how breaking changes ship): `https://docs.gunspec.io/en?section=versioning`
- OpenAPI, Postman, Swagger: `https://docs.gunspec.io/en?section=tools`
- AI and LLMs: `https://docs.gunspec.io/en?section=ai`

## Authentication

- Send the key in the `X-API-Key` header, or as `Authorization: Bearer <key>`. Both are accepted on every endpoint and neither is being retired; `X-API-Key` wins if you send both.
- Read it from an environment variable such as `GUNSPEC_API_KEY`. Never hardcode, commit, or log the key.
- Blog, changelog, and shared-collection reads need no key.

## Response envelope

- Success: `{ "success": true, "data": ... }`
- List endpoints also include `"pagination": { "page", "limit", "per_page" }`, plus `"total"` and `"totalPages"` on Builder and above
- Error: `{ "success": false, "error": { code, message, request_id } }`
- Not every success is JSON. `GET /ammunition/{id}/bullet.svg`, `GET /firearms/{id}/media/{selector}` and `GET /firearms/{id}/images/{imageId}` with `format=raw` (the default) and `GET /firearms/{id}/model` return the file's bytes, and `GET /out/{clickId}` answers a 302 redirect. Check `Content-Type` before parsing. The media page lists them: `https://docs.gunspec.io/en?section=media`
- Check `success` before reading `data`. Report `error.code` and `request_id` on failure.

## Errors and limits

- Codes: `VALIDATION_ERROR` (400), `UNAUTHORIZED` (401), `FORBIDDEN` (403), `SUBSCRIPTION_REQUIRED` (403), `NOT_FOUND` (404), `RATE_LIMITED` (429), `INTERNAL_ERROR` (500), `SERVICE_UNAVAILABLE` (503).
- `401` is a credential problem, whether missing, malformed or expired, so a new key can work. `403` is a permission one: plan too low, account suspended, or the key is not attached to the account or shop being acted on. A new key never clears a 403.
- `SUBSCRIPTION_REQUIRED` is a 403 with `details.requiredTier`: authenticated, but the plan does not cover the endpoint. Upgrade, do not retry.
- On 429, read `Retry-After` (seconds) and back off. It is the only rate-limit header sent. `X-RateLimit-Remaining` is not sent: the edge limiter reports whether a request was allowed, not how much budget is left. Track your own usage, or poll `GET /me/usage`.
- Rate limit per minute by tier: Explorer 10, Builder 60, Studio 120, Enterprise 300.

## Pagination

Use `page` (1-based) and `per_page`. `limit` is echoed back alongside `per_page`
with the same value, so read either.

**Do not drive the loop from `totalPages`.** On `/v1/firearms` the total is
suppressed for Anonymous and Explorer callers, so `pagination.totalPages` is
`undefined` there and a loop that tests it stops after one page. Page until a
short page comes back, and treat the total as an optional extra:

```js
let page = 1, per_page = 100, all = []
for (;;) {
  const { data, pagination } = await get(`/v1/firearms?page=${page}&per_page=${per_page}`)
  all.push(...data)
  if (data.length < per_page) break          // works on every tier
  if (pagination.totalPages && page >= pagination.totalPages) break
  page++
}
```

`per_page` maxes out at 100. The SDKs expose auto-paging helpers
(`listAutoPaging` in TypeScript, `list_auto_paging` in Python) that already do this.
Full rules, the per-plan page depth, the sequential-page burst limit and every
paged endpoint: `https://docs.gunspec.io/en?section=pagination`.

## Caching and refreshing

Caching is encouraged. The catalog is large and mostly static, and
re-downloading a record you already hold spends quota to learn nothing.

**Do not hardcode a lifetime.** Read `Cache-Control` off each response; the
per-resource table is at `https://docs.gunspec.io/en?section=caching`.

Three signals, answering different questions:

- `updatedAt`: when the record last changed. Maintained on a content
  comparison, so a re-import of identical data does not move it.
- `version`: a short hash of the record's own fields. Equal versions mean
  equal data, and it does not vary by plan. Store this beside a mirrored record.
- `ETag`: a hash of the exact bytes you were sent, for your plan. Send it back
  as `If-None-Match`.

```ts
const res = await fetch(`${BASE}/firearms/${record.id}`, {
  headers: {
    'X-API-Key': key,
    ...(record.etag ? { 'If-None-Match': record.etag } : {}),
  },
})
// 304: your copy is current. No body, and no charge against the daily allowance.
if (res.status === 304) return record
const body = await res.json()
return { ...body.data, etag: res.headers.get('ETag') }
```

A `304` counts toward the per-minute rate limit but not the daily allowance, so
checking often is cheap. Conditional requests are answered on the catalog reads
(firearms, manufacturers, calibers, ammunition, categories, attachments,
interfaces, platforms) plus `/stats/summary`, and not on the other statistics,
seller offers or `/user`.

Two mirroring rules: an `ETag` is per plan, so compare `version` across keys and
`ETag` only against a response on the same key; and `updatedAt` is a change
signal, not a freshness one; an old timestamp means nobody has needed to
correct that record.

## Being told, instead of asking

On Studio and Enterprise, `POST /me/webhooks` registers an endpoint and the
change comes to you, so a mirror needs no polling loop. The delivery carries the
record in the same shape `GET /firearms/{id}` returns it for your plan, `version`
included, so it can be written straight into your own store. A deletion carries
the id alone, the one change a poller cannot see, since a record it holds
simply stops appearing.

Events: `firearm.created` / `.updated` / `.deleted`, the same three for
`manufacturer.*` and `caliber.*`, plus `firearm.variant.updated` (the same
payload repeated for records that have a parent, so a family can be followed
without the whole catalog's traffic), `firearm.source.changed` and
`firearm.confidence.changed` (provenance moved; they arrive alongside
`firearm.updated`), and `catalog.resynced` (too many records changed at once to
describe individually, so re-sync rather than applying it).

Three rules for the receiver: dedupe on `X-Webhook-Id`, which is unchanged
across retries, not `X-Webhook-Delivery`, which is the attempt; verify
`X-Webhook-Signature` (`t=<unix>,v1=<hex>`, HMAC-SHA256 over `` `${t}.${rawBody}` ``
with the secret shown once at creation) against the raw bytes before trusting
the body; and answer 2xx quickly, doing the work afterwards, because a non-2xx
is retried three times, 1, 5 and 15 minutes apart, then given up on.

A webhook is a change signal, not a delivery guarantee. Keep the `If-None-Match`
refresh as the backstop and let webhooks decide when to run it.

## Key endpoints (relative to the base URL)

- `GET /firearms/resolve?q={name}`: a name as a person writes it to one id. Call this first when you start from text. `POST /firearms/resolve` resolves up to 50 at once (Studio)
- `GET /firearms`, `/firearms/search?q=`, `/firearms/compare?ids=`, `/firearms/random`, `/firearms/{id}`
- `GET /firearms/{id}/variants|images|silhouette|game-stats|dimensions|similar`
- `GET /ammunition`, `/calibers`, `/manufacturers`, `/categories`, `/countries`, `/conflicts`
- `GET /stats/summary`, `/game/tier-list`, `/game/balance-report`
- `GET /me/usage`, `/me/favorites` (needs a key)

## Resolving a firearm (do this first)

A user names a firearm; the API is addressed by slug. Never guess a slug -
`glock-19-gen5`, `glock-g19-gen5` and `glock-19` are not interchangeable, and a
guess returns 404 or, worse, a real record for a different variant.

1. `GET /firearms/resolve?q={what the user said}`. Pass their words through
   unchanged. It is built for the way people write: "G19 gen 5 MOS", "AK-47",
   "H&K MP5". Do not normalise, expand or correct the query yourself.
2. `status: "resolved"`: use `firearmId`.
3. `status: "ambiguous"`: **ask the user which one.** `firearmId` is null and
   `alternatives` holds the tied candidates with their names. Do not pick the
   first, the highest-scoring, or the most popular. "Glock 19" does not name a
   generation, and the generations differ in exactly the weights and dimensions
   people ask about.
4. `status: "not_found"`: say so. `alternatives` may hold `match: "fuzzy"`
   suggestions; those are offered, never resolved, and always score 0. Do not
   present one as the answer.
5. Read `unresolvedTokens` before answering. Those are words the resolver could
   not place, usually a variant the catalog does not hold. Mention them rather
   than answering as though they were not said.
6. Then `GET /firearms/{id}` for the full record; `GET /firearms/{id}/variants`
   for the rest of the family.

Resolving many names at once (a document, a thread, a spreadsheet column):
`POST /firearms/resolve` with `{"queries": [...]}`, up to 50, results in the
order sent. Studio and above.

`GET /firearms/search?q=` is still the right call when the user is browsing
rather than naming; it ranks records *about* a query. Resolve answers which
record a query *is*.

`GET /firearms/filter-options` returns the exact manufacturer, caliber, category
and country_of_origin values the list filters accept. Read it rather than
guessing. The country filter is `country_of_origin`, not `country`, and an
unknown parameter is ignored rather than rejected: a wrong filter name silently
returns the unfiltered list.

## Reading a record honestly

- **Never infer a missing specification.** `null` means GunSpec does not hold
  the value. Do not fill it from model knowledge, from a sibling variant, or by
  computing it from other fields. Say it is not in the database.
- **Units are in the field names**: `weightEmptyG` grams, `barrelLengthMm` and
  `overallLengthMm` millimetres, `muzzleVelocityMps` m/s. Read the suffix, state
  the unit. `GET /firearms/{id}/dimensions` gives metric and imperial together.
- **`dataConfidence` is not accuracy.** It is a 0-1 record-level score set from
  what was actually sourced and never raised by hand, not a per-field
  probability that a number is correct: 0.95 does not mean the barrel length is
  95% likely to be right. 0.5 with `verifiedAt` null is seed model knowledge;
  treat anything below 0.7 as unverified. Use it to rank and triage; follow
  `sources` (or the `provenance` object on a detail record) to verify one
  figure. Bands and the source order:
  `https://docs.gunspec.io/en?section=field-reference#data-confidence`
- **Specs legitimately differ** by production year, factory, batch and regional
  variant. Where a record notes disagreement between sources, surface it rather
  than presenting one number as settled.

## Tool layer (recommended shape)

Give the model a named tool per job rather than the raw API, and let the
workflow above drive the order. Generate them from the OpenAPI spec:

```
search_firearms(query)  -> GET /firearms/search
get_firearm(id)         -> GET /firearms/{id}
list_variants(id)       -> GET /firearms/{id}/variants
compare_firearms(ids)   -> GET /firearms/compare   (max 5 ids)
get_manufacturer(id)    -> GET /manufacturers/{id}
get_caliber(id)         -> GET /calibers/{id}
```

## Prefer the official SDKs

TypeScript / Node, `@buun_group/gunspec-sdk`:

```ts
import { GunSpec } from '@buun_group/gunspec-sdk'
const client = new GunSpec({ apiKey: process.env.GUNSPEC_API_KEY })
const { data } = await client.firearms.search({ q: 'ak-47' })
```

Python, `gunspec`:

```python
import os
from gunspec import GunSpec
client = GunSpec(api_key=os.environ["GUNSPEC_API_KEY"])
result = client.firearms.search({"q": "ak-47"})
```

## Raw HTTP

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/search?q=ak-47&per_page=5' \
  --header "X-API-Key: $GUNSPEC_API_KEY"
```

When unsure about a field or route, read `https://api.gunspec.io/openapi.json`
rather than guessing.
