---
name: gunspec-api
description: Query the GunSpec.io firearms REST API. Use when a task involves fetching firearms, ammunition, calibers, manufacturers, countries, or game-balance stats from GunSpec, or building an integration against api.gunspec.io. Covers authentication, the response envelope, pagination, error handling, and the official TypeScript and Python SDKs.
---

# GunSpec API

GunSpec.io is a firearms specification database: 9,000+ firearms with dimensions,
ballistics, materials, and game-balance stats, plus ammunition, calibers,
manufacturers, countries, conflicts, and aggregate statistics. This skill teaches
you to consume its REST API correctly.

## Discovery (source of truth)

- Base URL: `https://api.gunspec.io/v1`
- OpenAPI 3 spec: `https://api.gunspec.io/openapi.json`. Fetch this when you need an endpoint or field that is not listed below. Do not invent routes or fields.
- Human docs: `https://docs.gunspec.io`
- Machine index: `https://docs.gunspec.io/llms.txt`

## 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` request 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 (for example `GUNSPEC_API_KEY`). Never hardcode it, never commit it, never log it.
- A few read-only endpoints (blog, changelog, shared collections) need no key.
- Keys are issued at `https://gunspec.io/en/pricing`.

## 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" } }`
- Every response carries an `X-Request-Id` header.
- 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`

Always check `success` before reading `data`. On failure, surface `error.code`
and `request_id`.

## Error codes

`VALIDATION_ERROR` (400), `UNAUTHORIZED` (401), `FORBIDDEN` (403),
`SUBSCRIPTION_REQUIRED` (403), `NOT_FOUND` (404), `RATE_LIMITED` (429),
`INTERNAL_ERROR` (500), `SERVICE_UNAVAILABLE` (503).

`SUBSCRIPTION_REQUIRED` is a 403 carrying `details.requiredTier`: the caller is
authenticated but their plan does not include the endpoint. It is the response
the compatibility and Studio endpoints give a lower tier. Treat it as "upgrade",
never as "retry".

**401 and 403 mean different things and want different handling.** 401 says the
credential is missing, malformed or expired, so presenting a valid key can work.
403 says the key is valid and the caller is not permitted: the plan is below
the endpoint's tier, the account is suspended, or the key is not attached to the
thing being acted on (the `me` routes need a key that belongs to an account, the `vendor` routes need one a shop has named).
Reissuing a key never clears a 403; read `error.message` and surface it.

On 429, read `Retry-After` (seconds) and back off. It is the only rate-limit
header sent: the edge limiter reports whether a request was allowed, not how
much budget is left, so there is no `X-RateLimit-Remaining`. Track your own
usage, or poll `GET /me/usage`.
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`: list. Filters: `manufacturer`, `caliber`, `category`, `action_type`, `country_of_origin` (**not** `country`), `status`, `features`, `has_image`, `has_3d_model`, `year_introduced_min`/`_max`, `weight_min`/`_max`, `barrel_length_min`, `created_after`/`_before`. Sort with `sort` + `order`, trim the payload with `fields`. An unknown parameter is ignored, not rejected, so a wrong filter name silently returns the unfiltered list.
- `GET /firearms/resolve?q={name}`: a name as a person writes it ("G19 gen 5", "H&K MP5") to one id, with an honest `ambiguous` when it is several. Call this first when you start from text
- `POST /firearms/resolve`: the same for up to 50 names in one request (Studio)
- `GET /firearms/search?q={query}`: full-text search
- `GET /firearms/compare?ids={a},{b}`: side-by-side comparison
- `GET /firearms/random`: a random firearm
- `GET /firearms/{id}`: full specs (id is the slug, e.g. `glock-g17`)
- `GET /firearms/{id}/variants` | `/images` | `/silhouette` | `/game-stats` | `/dimensions` | `/similar`
- `GET /ammunition`, `/ammunition/{id}/ballistics`
- `GET /calibers`, `/calibers/{id}/ammunition`, `/calibers/ballistics`
- `GET /manufacturers`, `/manufacturers/{id}/firearms`
- `GET /categories`, `/categories/{slug}/firearms`
- `GET /countries`, `/countries/{code}/arsenal`, `/conflicts`
- `GET /stats/summary`, `/stats/by-category`
- `GET /game/tier-list`, `/game/matchups`, `/game/balance-report`
- `GET /me/usage`, `/me/favorites` (needs a key)

Attachment compatibility (what fits a firearm, computed from mount interfaces):
- `GET /attachments`: catalog, open to any key. `requires=<standard>` narrows by interface.
  **`fits=<firearm-id>` runs the compatibility engine and needs Studio**, like the endpoints below.
- `GET /attachments/{id}`, `GET /interfaces`: open
- `GET /firearms/{id}/attachments`, `/firearms/{id}/interfaces`: Studio
- `GET /attachments/{id}/firearms`, `/interfaces/{id}/firearms` (id URL-encoded): Studio
- `GET /platforms`, `/platforms/{id}`: Studio
- `GET /attachments/{id}/offers`, `/firearms/{id}/offers`: sellers stocking a record, open

Fit is computed from mount interfaces, never from names. Every fit carries
`source`, the weakest evidence behind it (`curated`, `universal`,
`inherited:parent`, `inherited:platform` or `inferred`), and a `confidence`
capped by the weakest interface it went through. Treat `inferred` as
unverified and pass `min_confidence` to hide fits below a figure you choose;
convention rows (a thread guessed from cartridge and country) are the weakest
evidence, at 0.6 or below, and are labelled. A caliber, bore or minimum-barrel
mismatch is never bridged by an adapter. A lower tier receives
`403 SUBSCRIPTION_REQUIRED`, not an empty list, so do not read an error as "nothing fits".

Discovery and analysis (tier in brackets):
- `GET /firearms/by-action` [open], `/by-designer` `/by-feature` `/by-material` [Builder], `/by-conflict` [Studio]
- `GET /firearms/timeline` `/top` `/head-to-head` `/power-rating` `/game-meta` [Builder]
- `GET /firearms/{id}/family-tree` [Builder], `/schematics` `/adoption-map` [Studio]
- `GET /calibers/compare`, `/calibers/{id}/family`, `/calibers/{id}/parent-chain` [Builder]
- `GET /manufacturers/{id}/stats`, `/manufacturers/{id}/timeline` [Builder]

Data quality [Enterprise]:
- `GET /data/confidence`: per-record confidence, lowest first
- `GET /data/coverage`: field coverage across the dataset

This list is a curated subset. The spec at `https://api.gunspec.io/openapi.json`
describes every path the API serves; fetch it rather than assuming an endpoint
is absent.

## 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}
```

## SDKs (prefer these over raw fetch)

TypeScript / Node:

```ts
import { GunSpec } from '@buun_group/gunspec-sdk'

const client = new GunSpec({ apiKey: process.env.GUNSPEC_API_KEY })
const { data, pagination } = await client.firearms.search({ q: 'ak-47' })

for await (const firearm of client.firearms.listAutoPaging({ category: 'rifle' })) {
  // each firearm, across all pages
}
```

Python:

```python
import os
from gunspec import GunSpec

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

for firearm in client.firearms.list_auto_paging({"category": "rifle"}):
    ...  # each firearm, across all pages
```

## 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"
```

## Checklist

1. Read the key from the environment. Never hardcode or print it.
2. Prefer the official SDK; otherwise fetch with the `X-API-Key` header.
3. Check `success` before `data`; report `error.code` and `request_id` on failure.
4. Handle 429 with `Retry-After` backoff.
5. When unsure, fetch `https://api.gunspec.io/openapi.json`. Do not guess fields or routes.
6. Resolve a name to an id with `/firearms/search` before fetching a record. Never guess a slug.
7. When search returns several variants, ask the user which one. Do not pick for them.
8. Never invent a missing specification. `null` means GunSpec does not hold it, so say so.
9. Report units from the field-name suffix (`...Mm`, `...G`, `...Mps`). Never assume one.
10. Treat `dataConfidence` as record completeness and provenance, not per-field accuracy. Cite `sources` for a specific figure.
11. Cache what you fetch, and refresh with `If-None-Match`. A record whose `version` has not changed does not need downloading again.
12. If you keep a mirror, subscribe to webhooks rather than polling for changes, and dedupe on `X-Webhook-Id`.
