# Firearms

Browse, search, compare and retrieve detailed specifications for 9,000+ firearms, variants included.

Source: https://docs.gunspec.io/en/api/firearms

```mermaid
flowchart TD
  words["What the user said<br/>'G19 gen 5 MOS'"] --> resolve["GET /v1/firearms/resolve?q="]
  resolve --> status{"status"}
  status -->|"exact"| id["firearmId is the record"]:::ok
  status -->|"ambiguous"| ask["Ask which one<br/>alternatives holds the ties"]:::warn
  status -->|"not_found"| none["Say we hold no such record"]:::muted
  id --> get["GET /v1/firearms/{id}"]
  ask --> get
  guess["Guessing a slug"] -.->|"404, or the wrong variant"| bad["Never guess"]:::fail
```

## List Firearms

`GET /v1/firearms`

Auth: API key required  
Tier: Explorer+

Returns a paginated list of firearms with optional filters. Filters combine with AND; `features` takes comma-separated slugs that must all match.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `page` | integer | no | Page number, from 1 to 10,000 |
| `per_page` | integer | no | Items per page (max 100) |
| `sort` | string | no | Sort field |
| `order` | string | no | Sort order |
| `fields` | string | no | Comma-separated list of fields to include |
| `manufacturer` | string | no | Filter by manufacturer slug |
| `caliber` | string | no | Filter by caliber slug |
| `category` | string | no | Filter by category slug |
| `action_type` | string | no | Filter by action type, as stored and underscored. The vocabulary is /v1/firearms/action-types |
| `country_of_origin` | string | no | Filter by ISO 3166-1 alpha-2 country code |
| `year_introduced_min` | integer | no | Minimum year introduced |
| `year_introduced_max` | integer | no | Maximum year introduced |
| `weight_min` | number | no | Minimum empty weight (grams) |
| `weight_max` | number | no | Maximum empty weight (grams) |
| `barrel_length_min` | number | no | Minimum barrel length (mm) |
| `status` | string | no | Filter by production status |
| `features` | string | no | Comma-separated feature slugs; all must match |
| `has_3d_model` | string | no | Only firearms with (or without) a 3D model |
| `has_image` | string | no | Only firearms with (or without) real imagery: a render or a photograph, ignoring the line-art silhouette every firearm has |
| `created_after` | string | no | Only records added after this ISO 8601 timestamp |
| `created_before` | string | no | Only records added before this ISO 8601 timestamp |
| `If-None-Match` | string | no | The `ETag` from a previous response. If the body would be byte-identical, the API answers `304 Not Modified` with no body. A 304 counts toward the per-minute rate limit but not the daily request allowance. |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | FirearmListItem[] | One page of results |
| `data[].id` | string | URL-safe slug identifying the firearm, e.g. `glock-17-gen5`. Stable: it is what a mirror keys on. |
| `data[].name` | string | Display name, as the maker writes it. |
| `data[].manufacturerId` | string | Slug of the manufacturer that makes it. Resolve against `/v1/manufacturers/{id}`. |
| `data[].categoryId` | string | Slug of the category it belongs to. Resolve against `/v1/categories`. |
| `data[].yearIntroduced` | integer \| null | Year it entered production or service. Null where the date is disputed or unknown. |
| `data[].status` | FirearmStatus \| null | Production status, one of `in_production`, `discontinued`, `out_of_production`, `in_service`, `limited_production`, `prototype`. Discontinued records are kept deliberately: a rifle built in 2004 wears parts nobody sells today. |
| `data[].countryOfOrigin` | string \| null | ISO 3166-1 alpha-2 code of where it was designed, e.g. `AT`. |
| `data[].actionType` | string \| null | How the action cycles: `short_recoil`, `gas_operated`, `blowback`, `bolt_action` and so on. An open vocabulary: the values in use are published on the schema as `x-gunspec-vocabulary`, and `GET /v1/firearms/action-types` is the live list. |
| `data[].weightEmptyG` | number \| null | Unloaded weight in grams, without magazine. |
| `data[].barrelLengthMm` | number \| null | Barrel length in millimetres, measured from the breech face. |
| `data[].images` | InlineMediaItem[] | Every image this firearm has, silhouette first. Empty when none are on file. |
| `data[].images[].id` | integer | Autoincrementing image id. |
| `data[].images[].url` | string | Absolute, directly fetchable URL. |
| `data[].images[].kind` | MediaKind | What kind of asset a media row is. |
| `data[].images[].alt` | string \| null | Alt text, where one has been written. Null otherwise, so do not render an empty string. |
| `data[].images[].width` | integer \| null | Pixel width, where it is known. |
| `data[].images[].height` | integer \| null | Pixel height, where it is known. |
| `data[].createdAt` | string | When the record was first added, `YYYY-MM-DD HH:MM:SS` in UTC. |
| `data[].svgLineArtUrl` | string \| null | Line-art silhouette, or null where none has been drawn. |
| `data[].model3dUrl` | string \| null | GLB model, or null where none is on file. |
| `data[].favoriteCount` | integer | How many accounts have favorited this record. |
| `data[].updatedAt` | string | When a field we serve last changed. Maintained by the database rather than by whatever wrote the row, so it moves for a corrected specification and does not move for a re-run of the importer. |
| `data[].version` | string \| null | Opaque fingerprint of this record. It changes when and only when a field we serve changes, so a client holding the same value already has the record and need not fetch it. Equality is the only supported operation: do not parse it, and do not assume how it is computed. Null means the record has not been written since versioning began, not that it is unchanged. |
| `pagination.page` | number | Current page number |
| `pagination.per_page` | number | Items per page |
| `pagination.total` | number | Total matching records (Builder and above) |

```json
{
  "success": true,
  "data": [
    {
      "id": "glock-17-gen5",
      "name": "Glock 17 Gen5",
      "manufacturerId": "glock",
      "categoryId": "pistol",
      "yearIntroduced": 2017,
      "status": "in_production",
      "countryOfOrigin": "AT",
      "actionType": "short_recoil",
      "weightEmptyG": 625,
      "barrelLengthMm": 114,
      "images": [
        {
          "id": 2572,
          "url": "https://api.gunspec.io/v1/firearms/ak-47/images/2572",
          "kind": "silhouette",
          "alt": "AK-47 right profile",
          "width": 1536,
          "height": 1024
        }
      ],
      "createdAt": "2026-09-06 23:55:07",
      "svgLineArtUrl": "https://api.gunspec.io/v1/firearms/glock-17-gen5/media/silhouette",
      "model3dUrl": null,
      "favoriteCount": 0,
      "updatedAt": "2026-09-10 06:42:19",
      "version": "a3f1c2d4e5b6a7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "per_page": 20,
    "total": 9162,
    "totalPages": 459
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms?category=pistol&per_page=5' \
  --header 'X-API-Key: your_key'
```

## Search Firearms

`GET /v1/firearms/search`

Auth: API key required  
Tier: Builder+

Searches firearm names and descriptions using SQLite FTS5. Results are ordered by relevance.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `q` | string | yes | Search query |
| `page` | integer | no | Page number, from 1 to 10,000 |
| `per_page` | integer | no | Items per page (max 100) |
| `If-None-Match` | string | no | The `ETag` from a previous response. If the body would be byte-identical, the API answers `304 Not Modified` with no body. A 304 counts toward the per-minute rate limit but not the daily request allowance. |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | FirearmSearchResult[] | One search hit. **The field names are snake_case here**, unlike `/v1/firearms` and the rest of the catalog: these are the search view's own columns and renaming them would break every consumer already reading them. `manufacturer_name` and `category_name` are joined in so a result list renders without a second call. |
| `data[].id` | string | URL-safe slug identifying the firearm. Stable: it is what a mirror keys on. |
| `data[].name` | string | Display name, as the maker writes it. |
| `data[].manufacturer_id` | string \| null | Slug of the manufacturer. `manufacturerId` on the camelCase endpoints. |
| `data[].category_id` | string \| null | Slug of the category. `categoryId` on the camelCase endpoints. |
| `data[].manufacturer_name` | string \| null | Display name of the manufacturer, joined in so a result row needs no second call. |
| `data[].category_name` | string \| null | Display name of the category. |
| `data[].category_slug` | string \| null | Slug of the category again, under the name the search view uses. Same value as `category_id`. |
| `data[].year_introduced` | integer \| null | Year the model was introduced. |
| `data[].status` | FirearmStatus \| null | Production status, one of `in_production`, `discontinued`, `out_of_production`, `in_service`, `limited_production`, `prototype`. Discontinued records are kept deliberately: a rifle built in 2004 wears parts nobody sells today. |
| `data[].country_of_origin` | string \| null | ISO 3166-1 alpha-2 code of the country of origin. |
| `data[].action_type` | string \| null | Operating action as stored, underscored, e.g. `short_recoil`, `bolt_action`, `gas_operated`. The full vocabulary is `/v1/firearms/action-types`; a hyphenated value matches nothing. |
| `data[].weight_empty_g` | number \| null | Unloaded weight in grams, without magazine. |
| `data[].barrel_length_mm` | number \| null | Barrel length in millimetres, measured from the breech face. |
| `data[].svg_line_art_url` | string \| null | Line-art silhouette, or null where none has been drawn. |
| `data[].model_3d_url` | string \| null | GLB model, or null where none is on file. |
| `data[].favorite_count` | integer | How many accounts have favorited this record. |
| `data[].rank` | number \| null | FTS5 relevance score the page is ordered by. Lower is a better match (SQLite bm25 scores are negative), and it is only comparable within one response. Absent or null when the query fell back to a plain substring match, which has no ranking. |
| `data[].images` | InlineMediaItem[] | Every image this firearm has, silhouette first. Empty when none are on file. |
| `data[].images[].id` | integer | Autoincrementing image id. |
| `data[].images[].url` | string | Absolute, directly fetchable URL. |
| `data[].images[].kind` | MediaKind | What kind of asset a media row is. |
| `data[].images[].alt` | string \| null | Alt text, where one has been written. Null otherwise, so do not render an empty string. |
| `data[].images[].width` | integer \| null | Pixel width, where it is known. |
| `data[].images[].height` | integer \| null | Pixel height, where it is known. |
| `pagination.page` | number | Current page number |
| `pagination.per_page` | number | Items per page |
| `pagination.total` | number | Total matching records (Builder and above) |

```json
{
  "success": true,
  "data": [
    {
      "id": "glock-17-gen5",
      "name": "Glock 17 Gen5",
      "manufacturer_id": "glock",
      "category_id": "pistol",
      "manufacturer_name": "Glock Ges.m.b.H.",
      "category_name": "Pistol",
      "category_slug": "pistol",
      "year_introduced": 2017,
      "status": "in_production",
      "country_of_origin": "AT",
      "action_type": "short_recoil",
      "weight_empty_g": 625,
      "barrel_length_mm": 114,
      "svg_line_art_url": null,
      "model_3d_url": null,
      "favorite_count": 0,
      "rank": -3.42,
      "images": [
        {
          "id": 2572,
          "url": "https://api.gunspec.io/v1/firearms/ak-47/images/2572",
          "kind": "silhouette",
          "alt": "AK-47 right profile",
          "width": 1536,
          "height": 1024
        }
      ]
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "per_page": 20,
    "total": 9162,
    "totalPages": 459
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- Requires an API key with **Builder** tier or higher.

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/search?q=beretta' \
  --header 'X-API-Key: your_key'
```

## Resolve a Name

`GET /v1/firearms/resolve`

Auth: API key required  
Tier: Builder+

Turns a name as somebody actually writes it ("G19 gen 5 MOS", "AK-47", "H&K MP5") into a single catalog id, or says why it cannot.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `q` | string | yes | The name to resolve, up to 200 characters. |
| `If-None-Match` | string | no | The `ETag` from a previous response. If the body would be byte-identical, the API answers `304 Not Modified` with no body. A 304 counts toward the per-minute rate limit but not the daily request allowance. |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object | What one query resolved to. |
| `data.query` | string | The query this result answers, echoed back so a batch response can be matched to its request. |
| `data.status` | string | One of `resolved` (exactly one record), `ambiguous` (several equally good, none chosen) or `not_found`. |
| `data.firearmId` | string \| null | The resolved catalog id. Null on `ambiguous` and `not_found`, since an ambiguous query is deliberately not decided for you. |
| `data.match` | string \| null | How the resolution was made, or null when nothing resolved. Never `fuzzy`: a suggestion is not a resolution. |
| `data.score` | number | Confidence in the resolution, 0 to 1. Derived from how much of the query a catalogued spelling accounted for and how strong that spelling is as evidence. |
| `data.matchedTokens` | string[] | The words of the query a catalogued spelling accounted for. |
| `data.unresolvedTokens` | string[] | The words it did not. Reported rather than guessed at: a variant we do not hold appears here instead of being silently dropped. |
| `data.alternatives` | object[] | Other records this query could mean, best first. On `ambiguous` these are the tied candidates and choosing between them is yours. |
| `data.alternatives[].firearmId` | string | The catalog id of this candidate. |
| `data.alternatives[].name` | string | The catalogued name of this candidate. |
| `data.alternatives[].manufacturerName` | string \| null | The maker, or null where the record has none. |
| `data.alternatives[].match` | string | How this candidate was matched. One of `slug`, `name`, `alternate`, `shorthand`, `base` (the name with its maker removed), `family` (the model designation with its trailing descriptor removed, e.g. `M4` from `M4 Carbine`) or `fuzzy`. |
| `data.alternatives[].matchedAlias` | string \| null | The catalogued spelling that matched, or null for a fuzzy suggestion. |
| `data.alternatives[].score` | number | Confidence in this candidate, 0 to 1. Always 0 for a fuzzy suggestion, which is offered rather than scored. |

```json
{
  "success": true,
  "data": {
    "query": "G19 gen 5 MOS",
    "status": "resolved",
    "firearmId": "glock-19-gen5",
    "match": "shorthand",
    "score": 0.6,
    "matchedTokens": [
      "g19"
    ],
    "unresolvedTokens": [
      "mos"
    ],
    "alternatives": [
      {
        "firearmId": "glock-19-gen5",
        "name": "Glock 19 Gen5",
        "manufacturerName": "Glock",
        "match": "shorthand",
        "matchedAlias": "G19 Gen5",
        "score": 0.6
      }
    ]
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- Requires an API key with **Builder** tier or higher.
- Call this before anything else when you are starting from text rather than from an id. Every other endpoint takes a catalog id; this is the only one that takes a name as somebody wrote it.
- It is not search. `/v1/firearms/search` ranks a page of records *about* a query and has no notion of certainty. This answers which record a query **is**, and tells you when it cannot.
- `status: "ambiguous"` is an answer, not a failure. "Glock 19" is three generations, so `firearmId` is null and all three come back in `alternatives` for you to choose between. Treating an ambiguous result as a resolution is how an agent ends up quoting the wrong barrel length.
- Words the resolver could not place come back in `unresolvedTokens` rather than being dropped. A variant we do not hold appears there, which is more useful than being handed the base model as though it were the answer.
- A `fuzzy` candidate is a suggestion offered when nothing matched at all. It always scores 0 and never appears as a resolution.
- A query that matches nothing is a `200` with `status: "not_found"`, not a `404`. The request was answerable; the answer is that we hold no such record.

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/resolve?q=G19%20gen%205%20MOS' \
  --header 'X-API-Key: your_key'
```

## Resolve Many Names

`POST /v1/firearms/resolve`

Auth: API key required  
Tier: Studio+

The batch form of name resolution: up to 50 queries, one round trip, results returned in the order they were sent.

### Request body (application/json)

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `queries` | string[] | yes | The names to resolve. Between 1 and 50, each up to 200 characters. |

```json
{
  "queries": [
    "AK-47"
  ]
}
```

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object | The response payload. |
| `data.results` | object[] | One result per query, in the order the queries were sent. |
| `data.results[].query` | string | The query this result answers, echoed back so a batch response can be matched to its request. |
| `data.results[].status` | string | One of `resolved` (exactly one record), `ambiguous` (several equally good, none chosen) or `not_found`. |
| `data.results[].firearmId` | string \| null | The resolved catalog id. Null on `ambiguous` and `not_found`, since an ambiguous query is deliberately not decided for you. |
| `data.results[].match` | string \| null | How the resolution was made, or null when nothing resolved. Never `fuzzy`: a suggestion is not a resolution. |
| `data.results[].score` | number | Confidence in the resolution, 0 to 1. Derived from how much of the query a catalogued spelling accounted for and how strong that spelling is as evidence. |
| `data.results[].matchedTokens` | string[] | The words of the query a catalogued spelling accounted for. |
| `data.results[].unresolvedTokens` | string[] | The words it did not. Reported rather than guessed at: a variant we do not hold appears here instead of being silently dropped. |
| `data.results[].alternatives` | object[] | Other records this query could mean, best first. On `ambiguous` these are the tied candidates and choosing between them is yours. |

```json
{
  "success": true,
  "data": {
    "results": [
      {
        "query": "G19 gen 5 MOS",
        "status": "resolved",
        "firearmId": "glock-19-gen5",
        "match": "shorthand",
        "score": 0.6,
        "matchedTokens": [
          "g19"
        ],
        "unresolvedTokens": [
          "mos"
        ],
        "alternatives": [
          {
            "firearmId": "glock-19-gen5",
            "name": "Glock 19 Gen5",
            "manufacturerName": "Glock",
            "match": "shorthand",
            "matchedAlias": "G19 Gen5",
            "score": 0.6
          }
        ]
      }
    ]
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 413 | `PAYLOAD_TOO_LARGE` | Request body too large |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- Requires an API key with **Studio** tier or higher.
- Up to 50 queries in one request, results returned in the order they were sent. Each is scored exactly as the single form scores it.
- This is the shape to use when extracting the firearms named in a document, a thread or a spreadsheet column. Resolving those one at a time is the difference between a call you can make inline and one you cannot.
- One unknown name does not fail the batch. It comes back as a result with `status: "not_found"`.

### Example

```bash
curl --request POST \
  --url 'https://api.gunspec.io/v1/firearms/resolve' \
  --header 'X-API-Key: your_key' \
  --header 'Content-Type: application/json' \
  --data '
{
  "queries": [
    "G19 gen 5",
    "HK MP5",
    "AK-47"
  ]
}
'
```

## Compare Firearms

`GET /v1/firearms/compare`

Auth: API key required  
Tier: Builder+

Returns full details for up to 5 firearms side by side.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `ids` | string | yes | Comma-separated firearm slugs, maximum 5 |
| `If-None-Match` | string | no | The `ETag` from a previous response. If the body would be byte-identical, the API answers `304 Not Modified` with no body. A 304 counts toward the per-minute rate limit but not the daily request allowance. |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object | The response payload. |
| `data.items` | FirearmWithRelations[] | The compared firearms, in the order their ids were given. |
| `data.items[].has3dModel` | integer | Integer flag, `1` when a 3D model is on file. Stored as the database holds it rather than as a boolean. |
| `data.items[].svgLineArtUrl` | string \| null | Line-art silhouette, or null where none has been drawn. |
| `data.items[].model3dUrl` | string \| null | GLB model, or null where none is on file. |
| `data.items[].defaultAmmoId` | string \| null | The ammunition load ballistics figures are quoted against, where one is nominated. |
| `data.items[].sourceMuzzleVelocityMps` | number \| null | Muzzle velocity in metres per second **exactly as the source stated it**, kept beside our own figure so a reader can see what was quoted and what was derived. |
| `data.items[].sourceMuzzleEnergyJ` | number \| null | Muzzle energy in joules exactly as the source stated it. |
| `data.items[].sourceEffectiveRangeM` | number \| null | Effective range in metres exactly as the source stated it. |
| `data.items[].sourceMaxRangeM` | number \| null | Maximum range in metres exactly as the source stated it. |
| `data.items[].ballisticsSource` | string \| null | Where the ballistics figures came from, named in prose. |
| `data.items[].ballisticsSourceUrl` | string \| null | URL of the ballistics source, where it is a page rather than a book. |
| `data.items[].id` | string | URL-safe slug identifying the firearm, e.g. `glock-17-gen5`. Stable: it is what a mirror keys on. |
| `data.items[].name` | string | Display name, as the maker writes it. |
| `data.deltas` | object[] | Per-field differences across the compared firearms, one entry per numeric field. |
| `data.deltas[].field` | string | Which column the row is about, named as the database names it. |
| `data.deltas[].values` | number \| null[] | This field on each compared firearm, in the order the ids were given. Null where that record does not carry the field. |
| `data.deltas[].min` | number \| null | Lowest value across the compared firearms. Null when fewer than two of them carry the field. |
| `data.deltas[].max` | number \| null | Highest value across the compared firearms. Null when fewer than two of them carry the field. |
| `data.deltas[].percentDiff` | number \| null | Spread from the lowest value, as a percentage of it: `(max - min) / min * 100`, rounded to two places. Null when the minimum is zero or fewer than two records carry the field. |

```json
{
  "success": true,
  "data": {
    "items": [
      {
        "has3dModel": 0,
        "svgLineArtUrl": "https://api.gunspec.io/v1/firearms/glock-17-gen5/media/silhouette",
        "model3dUrl": null,
        "defaultAmmoId": "m882",
        "sourceMuzzleVelocityMps": 375,
        "sourceMuzzleEnergyJ": 565,
        "sourceEffectiveRangeM": 50,
        "sourceMaxRangeM": 1800,
        "ballisticsSource": "Manufacturer specification sheet",
        "ballisticsSourceUrl": "https://eu.glock.com/en/pistols/g17",
        "id": "glock-17-gen5",
        "name": "Glock 17 Gen5",
        "manufacturerId": "glock",
        "categoryId": "pistol",
        "parentFirearmId": "glock-17",
        "variantType": "generation",
        "yearIntroduced": 2017,
        "yearDiscontinued": null,
        "status": "in_production",
        "countryOfOrigin": "AT",
        "weightEmptyG": 625,
        "weightLoadedG": 905,
        "overallLengthMm": 204,
        "barrelLengthMm": 114,
        "heightMm": 139,
        "widthMm": 34,
        "sightRadiusMm": 165,
        "actionType": "short_recoil",
        "firingMechanism": "striker-fired",
        "triggerType": "safe-action",
        "triggerPullN": 28,
        "magazineCapacity": 17,
        "magazineType": "detachable-box",
        "muzzleVelocityMps": 375,
        "muzzleEnergyJ": 570,
        "effectiveRangeM": 50,
        "maxRangeM": null,
        "rateOfFireRpm": null,
        "barrelRifling": "polygonal",
        "riflingTwistMm": 250,
        "numberOfGrooves": null,
        "frameMaterial": "polymer",
        "slideMaterial": "steel",
        "barrelMaterial": "steel",
        "stockMaterial": null,
        "finish": "nDLC",
        "safetyMechanisms": [
          "manual_safety_selector",
          "half_cock_notch"
        ],
        "features": [
          "collapsible_stock",
          "accessory_rail"
        ],
        "feedSystems": [
          "detachable_box_magazine"
        ],
        "gameDamage": null,
        "gameAccuracy": null,
        "gameRange": null,
        "gameFireRate": null,
        "gameMobility": null,
        "gameRecoilControl": null,
        "gameReloadSpeed": null,
        "gameConcealment": null,
        "description": null,
        "notes": null,
        "designer": "Mikhail Kalashnikov",
        "foldedLengthMm": 490,
        "alternateNames": [
          "Avtomat Kalashnikova",
          "Type 56",
          "Kalash"
        ],
        "firingModes": [
          "semi_automatic",
          "full_automatic"
        ],
        "conflicts": [
          {
            "name": "Vietnam War",
            "years": "1955-1975",
            "sides": [
              "North Vietnam / Viet Cong",
              "South Vietnam / United States"
            ]
          }
        ],
        "productionNumbers": {
          "estimated_total": 75000000,
          "production_years": "1947-1975",
          "notes": "Approximately 75 million AK-47 type rifles produced."
        },
        "lore": "The AK-47 is so iconic it appears on the national flag of Mozambique.",
        "sources": [
          "string"
        ],
        "dataConfidence": 0.95,
        "createdAt": "2025-01-15 12:00:00",
        "updatedAt": "2026-09-10 06:42:19",
        "version": "a3f1c2d4e5b6a7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2",
        "provenance": {
          "sources": [
            "https://www.glock.com/en/products/pistols/g17-gen5"
          ],
          "sourceKinds": [
            {
              "url": "https://www.glock.com/en/products/pistols/g17-gen5",
              "kind": "manufacturer"
            }
          ],
          "bestSourceKind": "manufacturer",
          "dataConfidence": 0.95,
          "verifiedAt": "2026-09-08",
          "verifiedFields": [
            "barrel_length_mm",
            "weight_empty_g"
          ],
          "specSource": null,
          "updatedAt": "2026-09-08 04:10:22",
          "version": "ba5c1d9e06c279a5"
        },
        "manufacturer": {
          "id": "glock",
          "name": "Glock Ges.m.b.H.",
          "countryCode": "AT",
          "foundedYear": 1963,
          "status": "active",
          "defunctYear": null,
          "stateOwned": false,
          "makesFirearms": true,
          "parentId": null,
          "predecessorId": null,
          "website": "https://www.glock.com",
          "logoUrl": null,
          "description": null,
          "createdAt": "2025-01-15 12:00:00",
          "updatedAt": "2026-09-10 06:42:19",
          "version": "a3f1c2d4e5b6a7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2"
        },
        "category": {
          "id": "pistol",
          "name": "Pistol",
          "description": "Handguns designed to be fired with one or two hands"
        },
        "calibers": [
          {
            "caliberId": "9x19mm-parabellum",
            "isPrimary": 1,
            "name": "9x19mm Parabellum",
            "natoDesignation": "9mm NATO",
            "bulletDiameterMm": 9.01,
            "caseLengthMm": 19.15,
            "cartridgeType": "rimless",
            "neckDiameterMm": 9.65,
            "shoulderDiameterMm": null,
            "baseDiameterMm": 9.93,
            "rimDiameterMm": 9.96,
            "rimThicknessMm": 1.27,
            "overallLengthMm": 29.69,
            "bulletLengthMm": 15.5,
            "typicalBulletWeightG": 7.45,
            "primerType": "small_pistol",
            "caseShape": "tapered",
            "caseMaterial": "brass",
            "projectileKind": "bullet",
            "bulletProfile": "round_nose",
            "closure": "bullet",
            "markingColor": null
          }
        ],
        "images": [
          {
            "id": 1,
            "firearmId": "glock-17-gen5",
            "url": "https://api.gunspec.io/v1/firearms/glock-17-gen5/images/3076",
            "type": "svg",
            "source": "manufacturer",
            "license": "fair-use",
            "kind": "silhouette",
            "storage": "cdn",
            "author": null,
            "sourceUrl": null,
            "alt": null,
            "width": 1200,
            "height": 800,
            "sortOrder": 0
          }
        ],
        "users": [
          {
            "id": 1,
            "firearmId": "glock-17-gen5",
            "userName": "Austrian Armed Forces",
            "userType": "military",
            "countryCode": "AT",
            "adoptedYear": 1982,
            "designation": "Pistole 80"
          }
        ]
      }
    ],
    "deltas": [
      {
        "field": "weightEmptyG",
        "values": [
          625
        ],
        "min": 625,
        "max": 945,
        "percentDiff": 51.2
      }
    ]
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- Requires an API key with **Builder** tier or higher.
- Maximum of 5 firearms per comparison request.

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/compare?ids=glock-17-gen5,sig-sauer-p320-full-size' \
  --header 'X-API-Key: your_key'
```

## Get Firearm

`GET /v1/firearms/{id}`

Auth: API key required  
Tier: Builder+

Returns the full specification for a single firearm, including its manufacturer, category, calibers, images, and known users.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | Resource slug ID |

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `If-None-Match` | string | no | The `ETag` from a previous response. If the body would be byte-identical, the API answers `304 Not Modified` with no body. A 304 counts toward the per-minute rate limit but not the daily request allowance. |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | FirearmDetail | A firearm as stored: every specification column we serve, with no nested relations. `FirearmDetail` is this plus them, and `/v1/firearms/{id}/family-tree` returns this shape for `current`, since the walk reads the row and does not load the relations. |
| `data.has3dModel` | integer | Integer flag, `1` when a 3D model is on file. Stored as the database holds it rather than as a boolean. |
| `data.svgLineArtUrl` | string \| null | Line-art silhouette, or null where none has been drawn. |
| `data.model3dUrl` | string \| null | GLB model, or null where none is on file. |
| `data.defaultAmmoId` | string \| null | The ammunition load ballistics figures are quoted against, where one is nominated. |
| `data.sourceMuzzleVelocityMps` | number \| null | Muzzle velocity in metres per second **exactly as the source stated it**, kept beside our own figure so a reader can see what was quoted and what was derived. |
| `data.sourceMuzzleEnergyJ` | number \| null | Muzzle energy in joules exactly as the source stated it. |
| `data.sourceEffectiveRangeM` | number \| null | Effective range in metres exactly as the source stated it. |
| `data.sourceMaxRangeM` | number \| null | Maximum range in metres exactly as the source stated it. |
| `data.ballisticsSource` | string \| null | Where the ballistics figures came from, named in prose. |
| `data.ballisticsSourceUrl` | string \| null | URL of the ballistics source, where it is a page rather than a book. |
| `data.id` | string | URL-safe slug identifying the firearm, e.g. `glock-17-gen5`. Stable: it is what a mirror keys on. |
| `data.name` | string | Display name, as the maker writes it. |
| `data.manufacturerId` | string | Slug of the manufacturer that makes it. Resolve against `/v1/manufacturers/{id}`. |
| `data.categoryId` | string | Slug of the category it belongs to. Resolve against `/v1/categories`. |
| `data.parentFirearmId` | string \| null | The firearm this one is a variant of, or null when it is not a variant. Variant relationships are what `firearm.variant.updated` webhooks and `/v1/firearms/{id}/variants` follow. |
| `data.variantType` | string \| null | How this record differs from its parent: `optics_ready`, `compact`, `threaded` and so on. Null when the record has no parent. |
| `data.yearIntroduced` | integer \| null | Year it entered production or service. Null where the date is disputed or unknown. |
| `data.yearDiscontinued` | integer \| null | Year production ended, or null while it is still made. |
| `data.status` | FirearmStatus \| null | Production status, one of `in_production`, `discontinued`, `out_of_production`, `in_service`, `limited_production`, `prototype`. Discontinued records are kept deliberately: a rifle built in 2004 wears parts nobody sells today. |
| `data.countryOfOrigin` | string \| null | ISO 3166-1 alpha-2 code of where it was designed, e.g. `AT`. |
| `data.weightEmptyG` | number \| null | Unloaded weight in grams, without magazine. |
| `data.weightLoadedG` | number \| null | Loaded weight in grams, with a full magazine. |
| `data.overallLengthMm` | number \| null | Overall length in millimetres, stock extended where it folds or collapses. |
| `data.barrelLengthMm` | number \| null | Barrel length in millimetres, measured from the breech face. |
| `data.heightMm` | number \| null | Height in millimetres, including sights and a fitted magazine. |
| `data.widthMm` | number \| null | Width in millimetres at the widest point. |
| `data.sightRadiusMm` | number \| null | Distance between front and rear iron sights in millimetres. Null on a record with no iron sights. |
| `data.actionType` | string \| null | How the action cycles: `short_recoil`, `gas_operated`, `blowback`, `bolt_action` and so on. An open vocabulary: the values in use are published on the schema as `x-gunspec-vocabulary`, and `GET /v1/firearms/action-types` is the live list. |
| `data.firingMechanism` | string \| null | What releases the striker or hammer: `striker_fired`, `hammer_fired`. |
| `data.triggerType` | string \| null | Trigger arrangement: `single_action`, `double_action`, `safe_action`. |
| `data.triggerPullN` | number \| null | Trigger pull weight in newtons. Divide by 4.448 for pounds-force. |
| `data.magazineCapacity` | integer \| null | Rounds in the standard magazine. Null where the firearm is not magazine-fed. |
| `data.magazineType` | string \| null | Magazine pattern: `detachable_box`, `internal`, `drum`, `belt`. |
| `data.muzzleVelocityMps` | number \| null | Muzzle velocity in metres per second, for the default load. |
| `data.muzzleEnergyJ` | number \| null | Muzzle energy in joules, for the default load. |
| `data.effectiveRangeM` | number \| null | Effective range in metres against a point target. |
| `data.maxRangeM` | number \| null | Maximum range in metres. Where the projectile lands, not where it is useful. |
| `data.rateOfFireRpm` | integer \| null | Cyclic rate in rounds per minute. Null on anything that is not automatic. |
| `data.barrelRifling` | string \| null | Rifling description, e.g. `6 grooves, right-hand twist`. |
| `data.riflingTwistMm` | number \| null | Rifling twist rate in millimetres per turn. Divide by 25.4 for inches. |
| `data.numberOfGrooves` | integer \| null | Number of grooves cut in the bore. |
| `data.frameMaterial` | string \| null | What the frame or receiver is made of. |
| `data.slideMaterial` | string \| null | What the slide is made of. Null on anything without one. |
| `data.barrelMaterial` | string \| null | What the barrel is made of. |
| `data.stockMaterial` | string \| null | What the stock or furniture is made of. |
| `data.finish` | string \| null | Surface finish, e.g. `nitride`, `parkerized`, `cerakote`. |
| `data.safetyMechanisms` | string[] | Safety mechanisms the design carries. |
| `data.features` | string[] | Notable features of the design. |
| `data.feedSystems` | string[] | How the firearm is fed. |
| `data.gameDamage` | integer \| null | Editorial game statistic, 0-100. **Not a measured figure.** These are balance numbers for game use and are not derived from the ballistics above. |
| `data.gameAccuracy` | integer \| null | Editorial game statistic, 0-100. Not a measured figure. |
| `data.gameRange` | integer \| null | Editorial game statistic, 0-100. Not a measured figure. |
| `data.gameFireRate` | integer \| null | Editorial game statistic, 0-100. Not a measured figure. |
| `data.gameMobility` | integer \| null | Editorial game statistic, 0-100. Not a measured figure. |
| `data.gameRecoilControl` | integer \| null | Editorial game statistic, 0-100. Not a measured figure. |
| `data.gameReloadSpeed` | integer \| null | Editorial game statistic, 0-100. Not a measured figure. |
| `data.gameConcealment` | integer \| null | Editorial game statistic, 0-100. Not a measured figure. |
| `data.description` | string \| null | Prose summary of the record, where one has been written. |
| `data.notes` | string \| null | Free-text notes that do not belong in a specific field. |
| `data.designer` | string \| null | Person or team credited with the design, where it is known. |
| `data.foldedLengthMm` | number \| null | Length with stock folded (mm) |
| `data.alternateNames` | string[] | Other names and designations the same firearm is known by. |
| `data.firingModes` | string[] | Firing modes the selector offers. |
| `data.conflicts` | object[] | Conflicts the firearm saw service in. |
| `data.conflicts[].name` | string | The conflict. |
| `data.conflicts[].years` | string | When it ran, as written. |
| `data.conflicts[].sides` | string[] | The belligerents. |
| `data.productionNumbers` | object \| null | How many were made, and over what period. |
| `data.productionNumbers.estimated_total` | integer \| null | Units believed produced. |
| `data.productionNumbers.production_years` | string \| null | The production run, as written. |
| `data.productionNumbers.notes` | string \| null | What the estimate covers. |
| `data.lore` | string \| null | Historical trivia / game-dev flavor text |
| `data.sources` | string[] | Source URLs the record was compiled from. Check a specific figure against these rather than against `dataConfidence`. The same list is in `provenance.sources`. |
| `data.dataConfidence` | number \| null | How completely the record is specified and how well it is sourced, 0 to 1. A record-level completeness and provenance measure, **not** a per-field probability of correctness. Use it to rank and triage; use `sources` to verify an individual number. |
| `data.createdAt` | string | When the record was first added, `YYYY-MM-DD HH:MM:SS` in UTC. |
| `data.updatedAt` | string | When a field we serve last changed. Maintained by the database rather than by whatever wrote the row, so it moves for a corrected specification and does not move for a re-run of the importer. |
| `data.version` | string \| null | Opaque fingerprint of this record. It changes when and only when a field we serve changes, so a client holding the same value already has the record and need not fetch it. Equality is the only supported operation: do not parse it, and do not assume how it is computed. Null means the record has not been written since versioning began, not that it is unchanged. |
| `data.provenance` | Provenance | Where a record's figures came from and how far they have been checked, in one place. `sources` are the pages consulted; check a specific figure against those. `sourceKinds` says what each page is and `bestSourceKind` the strongest of them, on the hierarchy `SourceKind` defines: a maker or standards-body page is evidence for a figure, a retailer or forum page is evidence the item exists. `dataConfidence` is the 0 to 1 score set from what was actually sourced and never raised by hand. `verifiedAt` and `verifiedFields` say when a source was last read against the record and which fields it stated; null means the row is still seed knowledge. `updatedAt` and `version` are the same cache signals the record carries at the top level. |
| `data.provenance.sources` | string[] | The pages consulted when the record was compiled, as an array. Check a specific figure against these rather than against `dataConfidence`. |
| `data.provenance.sourceKinds` | object[] | What each cited page is, in `sources` order, on the `SourceKind` hierarchy. A host not in the source map is `other`, never guessed. |
| `data.provenance.bestSourceKind` | SourceKind \| null | The strongest kind among the citations, or null when nothing is cited. `manufacturer` or `standards_body` means a figure can be checked against an authority; `retailer` or `community` alone means the record is still supported by copies of copies. |
| `data.provenance.dataConfidence` | number \| null | The 0 to 1 score set from what was actually sourced, never raised by hand. See the confidence bands in the docs. |
| `data.provenance.verifiedAt` | string \| null | When a source was last read against this record. Null means the row is still seed knowledge. |
| `data.provenance.verifiedFields` | string[] | Which fields the source stated, as an array. Everything else on a verified row is still unverified. Null where no source has been read. |
| `data.provenance.specSource` | string \| null | Cartridges only: the page the drawing figures were taken from. Null on every other kind of record. |
| `data.provenance.updatedAt` | string | When a served column last changed; the same value the record carries at the top level. |
| `data.provenance.version` | string \| null | The record's content version, equal to the top-level `version`; equal versions mean equal data. |
| `data.manufacturer` | Manufacturer \| null | Null when the record names a manufacturer id the catalog no longer holds. |
| `data.category` | Category \| null | Null when the record names a category id the catalog no longer holds. |
| `data.calibers` | FirearmCaliber[] | Every cartridge this firearm is chambered for, primary first. |
| `data.calibers[].caliberId` | string | Slug of the cartridge. Resolve against `/v1/calibers/{id}`. |
| `data.calibers[].isPrimary` | integer | Integer flag, `1` on the chambering the firearm is normally sold in. A firearm may be chambered for several cartridges but has at most one primary. |
| `data.calibers[].name` | string | Display name, as the maker writes it. |
| `data.calibers[].natoDesignation` | string \| null | NATO designation where the cartridge has one, e.g. `9mm NATO`. Null otherwise. |
| `data.calibers[].bulletDiameterMm` | number \| null | Bullet diameter in millimetres. |
| `data.calibers[].caseLengthMm` | number \| null | Case length in millimetres. |
| `data.calibers[].cartridgeType` | string \| null | Head type: `rimmed`, `rimless`, `semi_rimmed`, `rebated_rim`, `belted`, plus historical values like `cap_and_ball`. **Not the broad class.** The registry said `pistol`/`rifle`/`shotgun` until the column was read, and it holds neither. |
| `data.calibers[].neckDiameterMm` | number \| null | Case neck outside diameter in millimetres. |
| `data.calibers[].shoulderDiameterMm` | number \| null | Bottleneck cases only. |
| `data.calibers[].baseDiameterMm` | number \| null | Case base diameter in millimetres, ahead of the extractor groove. |
| `data.calibers[].rimDiameterMm` | number \| null | Rim diameter in millimetres. |
| `data.calibers[].rimThicknessMm` | number \| null | Rim thickness in millimetres. |
| `data.images` | FirearmImage[] | Every image on file, silhouette first. Each carries an absolute, directly fetchable `url`. |
| `data.images[].id` | integer | Autoincrementing image id. Numeric, unlike the catalog slugs. |
| `data.images[].firearmId` | string | Slug of the firearm this row belongs to. |
| `data.images[].url` | string | Absolute, directly fetchable URL. |
| `data.images[].type` | ImageType \| null | Role of the image: `primary`, `gallery`, `thumbnail`, `svg`, `render`. |
| `data.images[].source` | string \| null | Where the record came from: a curated entry, an inference, or an import. |
| `data.images[].license` | string \| null | Licence the asset is held under, where one is recorded. |
| `data.images[].kind` | MediaKind \| null | What the asset is: `silhouette`, `render`, `photo`, `schematic`, `model`. `/v1/firearms/{id}/media/{selector}` addresses an asset by this. |
| `data.images[].storage` | string \| null | Where the bytes live: `cdn` for a static path, `r2` for an upload we serve. |
| `data.images[].author` | string \| null | Who made the asset, where it is credited. |
| `data.images[].sourceUrl` | string \| null | Page the asset or document was taken from. |
| `data.images[].alt` | string \| null | Alt text, where one has been written. Null otherwise, so do not render an empty string. |
| `data.images[].width` | integer \| null | Pixel width, where it is known. |
| `data.users` | FirearmUser[] | Forces and agencies that have adopted it. |
| `data.users[].id` | integer | Autoincrementing row id. |
| `data.users[].firearmId` | string | Slug of the firearm this row belongs to. |
| `data.users[].userName` | string | The force, agency or organisation that adopted it. |
| `data.users[].userType` | string \| null | What kind of user: `military`, `police`, `civilian`. |
| `data.users[].countryCode` | string \| null | ISO 3166-1 alpha-2 code of the user's country. |
| `data.users[].adoptedYear` | integer \| null | Year of adoption, where it is known. |
| `data.users[].designation` | string \| null | What that user calls it, where it differs from the maker's name. |
| `data.schematics` | FirearmSchematic[] | Exploded-view and parts diagrams on file, empty where none have been sourced. |
| `data.schematics[].id` | integer | Autoincrementing schematic id. |
| `data.schematics[].firearmId` | string | Slug of the firearm this row belongs to. |
| `data.schematics[].title` | string | What the document is called. |
| `data.schematics[].type` | SchematicType | What a schematic document is. |
| `data.schematics[].url` | string | Absolute, directly fetchable URL. |
| `data.schematics[].format` | string \| null | File format, e.g. `pdf`. |
| `data.schematics[].version` | string \| null | Revision of the document, where the publisher versions it. |
| `data.schematics[].manufacturer` | string \| null | Publisher of the document, often but not always the maker. |
| `data.schematics[].source` | string \| null | Where the document came from. |
| `data.schematics[].sourceUrl` | string \| null | Page the asset or document was taken from. |
| `data.schematics[].author` | string \| null | Who made the asset, where it is credited. |
| `data.schematics[].license` | string \| null | Licence the asset is held under, where one is recorded. |

```json
{
  "success": true,
  "data": {
    "has3dModel": 0,
    "svgLineArtUrl": "https://api.gunspec.io/v1/firearms/glock-17-gen5/media/silhouette",
    "model3dUrl": null,
    "defaultAmmoId": "m882",
    "sourceMuzzleVelocityMps": 375,
    "sourceMuzzleEnergyJ": 565,
    "sourceEffectiveRangeM": 50,
    "sourceMaxRangeM": 1800,
    "ballisticsSource": "Manufacturer specification sheet",
    "ballisticsSourceUrl": "https://eu.glock.com/en/pistols/g17",
    "id": "glock-17-gen5",
    "name": "Glock 17 Gen5",
    "manufacturerId": "glock",
    "categoryId": "pistol",
    "parentFirearmId": "glock-17",
    "variantType": "generation",
    "yearIntroduced": 2017,
    "yearDiscontinued": null,
    "status": "in_production",
    "countryOfOrigin": "AT",
    "weightEmptyG": 625,
    "weightLoadedG": 905,
    "overallLengthMm": 204,
    "barrelLengthMm": 114,
    "heightMm": 139,
    "widthMm": 34,
    "sightRadiusMm": 165,
    "actionType": "short_recoil",
    "firingMechanism": "striker-fired",
    "triggerType": "safe-action",
    "triggerPullN": 28,
    "magazineCapacity": 17,
    "magazineType": "detachable-box",
    "muzzleVelocityMps": 375,
    "muzzleEnergyJ": 570,
    "effectiveRangeM": 50,
    "maxRangeM": null,
    "rateOfFireRpm": null,
    "barrelRifling": "polygonal",
    "riflingTwistMm": 250,
    "numberOfGrooves": null,
    "frameMaterial": "polymer",
    "slideMaterial": "steel",
    "barrelMaterial": "steel",
    "stockMaterial": null,
    "finish": "nDLC",
    "safetyMechanisms": [
      "manual_safety_selector",
      "half_cock_notch"
    ],
    "features": [
      "collapsible_stock",
      "accessory_rail"
    ],
    "feedSystems": [
      "detachable_box_magazine"
    ],
    "gameDamage": null,
    "gameAccuracy": null,
    "gameRange": null,
    "gameFireRate": null,
    "gameMobility": null,
    "gameRecoilControl": null,
    "gameReloadSpeed": null,
    "gameConcealment": null,
    "description": null,
    "notes": null,
    "designer": "Mikhail Kalashnikov",
    "foldedLengthMm": 490,
    "alternateNames": [
      "Avtomat Kalashnikova",
      "Type 56",
      "Kalash"
    ],
    "firingModes": [
      "semi_automatic",
      "full_automatic"
    ],
    "conflicts": [
      {
        "name": "Vietnam War",
        "years": "1955-1975",
        "sides": [
          "North Vietnam / Viet Cong",
          "South Vietnam / United States"
        ]
      }
    ],
    "productionNumbers": {
      "estimated_total": 75000000,
      "production_years": "1947-1975",
      "notes": "Approximately 75 million AK-47 type rifles produced."
    },
    "lore": "The AK-47 is so iconic it appears on the national flag of Mozambique.",
    "sources": [
      "string"
    ],
    "dataConfidence": 0.95,
    "createdAt": "2025-01-15 12:00:00",
    "updatedAt": "2026-09-10 06:42:19",
    "version": "a3f1c2d4e5b6a7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2",
    "provenance": {
      "sources": [
        "https://www.glock.com/en/products/pistols/g17-gen5"
      ],
      "sourceKinds": [
        {
          "url": "https://www.glock.com/en/products/pistols/g17-gen5",
          "kind": "manufacturer"
        }
      ],
      "bestSourceKind": "manufacturer",
      "dataConfidence": 0.95,
      "verifiedAt": "2026-09-08",
      "verifiedFields": [
        "barrel_length_mm",
        "weight_empty_g"
      ],
      "specSource": null,
      "updatedAt": "2026-09-08 04:10:22",
      "version": "ba5c1d9e06c279a5"
    },
    "manufacturer": {
      "id": "glock",
      "name": "Glock Ges.m.b.H.",
      "countryCode": "AT",
      "foundedYear": 1963,
      "status": "active",
      "defunctYear": null,
      "stateOwned": false,
      "makesFirearms": true,
      "parentId": null,
      "predecessorId": null,
      "website": "https://www.glock.com",
      "logoUrl": null,
      "description": null,
      "createdAt": "2025-01-15 12:00:00",
      "updatedAt": "2026-09-10 06:42:19",
      "version": "a3f1c2d4e5b6a7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2"
    },
    "category": {
      "id": "pistol",
      "name": "Pistol",
      "description": "Handguns designed to be fired with one or two hands"
    },
    "calibers": [
      {
        "caliberId": "9x19mm-parabellum",
        "isPrimary": 1,
        "name": "9x19mm Parabellum",
        "natoDesignation": "9mm NATO",
        "bulletDiameterMm": 9.01,
        "caseLengthMm": 19.15,
        "cartridgeType": "rimless",
        "neckDiameterMm": 9.65,
        "shoulderDiameterMm": null,
        "baseDiameterMm": 9.93,
        "rimDiameterMm": 9.96,
        "rimThicknessMm": 1.27,
        "overallLengthMm": 29.69,
        "bulletLengthMm": 15.5,
        "typicalBulletWeightG": 7.45,
        "primerType": "small_pistol",
        "caseShape": "tapered",
        "caseMaterial": "brass",
        "projectileKind": "bullet",
        "bulletProfile": "round_nose",
        "closure": "bullet",
        "markingColor": null
      }
    ],
    "images": [
      {
        "id": 1,
        "firearmId": "glock-17-gen5",
        "url": "https://api.gunspec.io/v1/firearms/glock-17-gen5/images/3076",
        "type": "svg",
        "source": "manufacturer",
        "license": "fair-use",
        "kind": "silhouette",
        "storage": "cdn",
        "author": null,
        "sourceUrl": null,
        "alt": null,
        "width": 1200,
        "height": 800,
        "sortOrder": 0
      }
    ],
    "users": [
      {
        "id": 1,
        "firearmId": "glock-17-gen5",
        "userName": "Austrian Armed Forces",
        "userType": "military",
        "countryCode": "AT",
        "adoptedYear": 1982,
        "designation": "Pistole 80"
      }
    ],
    "schematics": [
      {
        "id": 12,
        "firearmId": "glock-17-gen5",
        "title": "Exploded view",
        "type": "blueprint",
        "url": "https://assets.gunspec.io/firearms/schematics/glock-17-gen5-exploded.pdf",
        "format": "pdf",
        "version": null,
        "manufacturer": "Glock Ges.m.b.H.",
        "source": null,
        "sourceUrl": null,
        "author": null,
        "license": null,
        "createdAt": "2026-05-26 12:36:27"
      }
    ]
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 404 | `NOT_FOUND` | Resource not found |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- Requires an API key with **Builder** tier or higher.
- `alternateNames`, `firingModes`, `safetyMechanisms`, `features`, `feedSystems`, `sources`, `conflicts` and `productionNumbers` are arrays and objects, not strings. They were served as JSON text and had to be parsed by the caller; a cell that does not parse is now `null` rather than the raw text.
- `conflicts` contains objects with `name`, `years`, and `sides` (array of belligerent strings).
- `productionNumbers` contains `estimated_total` (number), `production_years` (string), and `notes` (string).
- The `schematics` array (blueprints, spec sheets) is included only for **Studio** tier or higher; it is omitted otherwise. See the Schematics endpoint for the full document shape.

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/ak-47' \
  --header 'X-API-Key: your_key'
```

## List Variants

`GET /v1/firearms/{id}/variants`

Auth: API key required  
Tier: Explorer+

Returns the direct variants of a firearm (its immediate children).

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | Firearm slug |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | FirearmVariant[] | The response payload. |
| `data[].id` | string | URL-safe slug identifying the firearm, e.g. `glock-17-gen5`. |
| `data[].name` | string | Display name, as the maker writes it. |
| `data[].variantType` | string \| null | How this record differs from its parent: `optics_ready`, `compact`, `threaded` and so on. Null when the record has no parent. |
| `data[].yearIntroduced` | integer \| null | Year it entered production or service. Null where the date is disputed or unknown. |
| `data[].status` | FirearmStatus \| null | Production status, one of `in_production`, `discontinued`, `out_of_production`, `in_service`, `limited_production`, `prototype`. Discontinued records are kept deliberately: a rifle built in 2004 wears parts nobody sells today. |

```json
{
  "success": true,
  "data": [
    {
      "id": "glock-17-gen5-mos",
      "name": "Glock 17 Gen5 MOS",
      "variantType": "optics-ready",
      "yearIntroduced": 2018,
      "status": "in_production"
    }
  ]
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 404 | `NOT_FOUND` | Resource not found |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/ak-47/variants' \
  --header 'X-API-Key: your_key'
```

## Get Images

`GET /v1/firearms/{id}/images`

Auth: API key required  
Tier: Explorer+

Returns every image associated with a firearm, with credits and licensing.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | Resource slug ID |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | FirearmImage[] | The response payload. |
| `data[].id` | integer | Autoincrementing image id. Numeric, unlike the catalog slugs. |
| `data[].firearmId` | string | Slug of the firearm this row belongs to. |
| `data[].url` | string | Absolute, directly fetchable URL. |
| `data[].type` | ImageType \| null | Role of the image: `primary`, `gallery`, `thumbnail`, `svg`, `render`. |
| `data[].source` | string \| null | Where the record came from: a curated entry, an inference, or an import. |
| `data[].license` | string \| null | Licence the asset is held under, where one is recorded. |
| `data[].kind` | MediaKind \| null | What the asset is: `silhouette`, `render`, `photo`, `schematic`, `model`. `/v1/firearms/{id}/media/{selector}` addresses an asset by this. |
| `data[].storage` | string \| null | Where the bytes live: `cdn` for a static path, `r2` for an upload we serve. |
| `data[].author` | string \| null | Who made the asset, where it is credited. |
| `data[].sourceUrl` | string \| null | Page the asset or document was taken from. |
| `data[].alt` | string \| null | Alt text, where one has been written. Null otherwise, so do not render an empty string. |
| `data[].width` | integer \| null | Pixel width, where it is known. |
| `data[].height` | integer \| null | Pixel height, where it is known. |
| `data[].sortOrder` | integer | Position within its list, ascending. |

```json
{
  "success": true,
  "data": [
    {
      "id": 1,
      "firearmId": "glock-17-gen5",
      "url": "https://api.gunspec.io/v1/firearms/glock-17-gen5/images/3076",
      "type": "svg",
      "source": "manufacturer",
      "license": "fair-use",
      "kind": "silhouette",
      "storage": "cdn",
      "author": null,
      "sourceUrl": null,
      "alt": null,
      "width": 1200,
      "height": 800,
      "sortOrder": 0
    }
  ]
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 404 | `NOT_FOUND` | Resource not found |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- Requires **Studio** tier or higher.
- Signed URLs expire after 1 hour. Regenerate as needed.

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/glock-17-gen5/images' \
  --header 'X-API-Key: your_key'
```

## Get image asset

`GET /v1/firearms/{id}/images/{imageId}`

Auth: API key required  
Tier: Builder+

Returns the image bytes for one gallery entry. Defaults to the raw file with an immutable cache header; `format=datauri` returns a base64 data URI that drops straight into an <img src> with no second request. Images we link rather than host (external sources, asset-CDN paths) answer with a 302 to their location. Listing images and their credits is explorer-tier via /v1/firearms/{id}/images; the bytes are builder-tier, like the silhouette.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | Resource slug ID |
| `imageId` | string | yes | Image row id from /v1/firearms/{id}/images |

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `format` | string | no | raw file, or a base64 data URI |
| `variant` | string | no | Pre-rendered derivative; falls back to the original when absent |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object | The response payload. |
| `data.id` | string | Slug of the firearm the image belongs to. |
| `data.imageId` | integer | Numeric id of the image within that firearm image set. |
| `data.variant` | string | Size rendered: `original`, `display` or `thumb`. |
| `data.format` | string | Encoding of the returned image. `datauri` is the only format this route emits. |
| `data.mimeType` | string | Media type of the encoded bytes. |
| `data.dataUri` | string | The image inlined as an RFC 2397 data URI, ready to use as an `<img src>` with no second request. |

```json
{
  "success": true,
  "data": {
    "id": "glock-17-gen5",
    "imageId": 6563,
    "variant": "thumb",
    "format": "datauri",
    "mimeType": "image/webp",
    "dataUri": "data:image/webp;base64,UklGRi..."
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 404 | `NOT_FOUND` | Resource not found |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/glock-17-gen5/images/6563' \
  --header 'X-API-Key: your_key'
```

## Index every firearm that has imagery

`GET /v1/firearms/media`

Auth: API key required  
Tier: Explorer+

Returns each firearm with a render or photograph, and that imagery, as a three-field row. Built for media grids and mirroring jobs: paging the full catalogue with `has_image=true` returns fifteen fields per row, twelve of which such a caller discards. Silhouettes are excluded; every firearm has one. `per_page` allows up to 1000 here for the same reason.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `page` | integer | no | Page number |
| `per_page` | integer | no | Firearms per page |
| `kind` | string | no | Restrict the images on each row to one kind |
| `If-None-Match` | string | no | The `ETag` from a previous response. If the body would be byte-identical, the API answers `304 Not Modified` with no body. A 304 counts toward the per-minute rate limit but not the daily request allowance. |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | MediaCatalogEntry[] | One firearm and the real imagery it has. Deliberately three fields: this is the index for a media grid or a mirroring job, not a catalogue row. Silhouettes are excluded: every firearm has one, and they have their own build-time manifest. |
| `data[].id` | string | Slug of the firearm this row is about. |
| `data[].name` | string | Display name, as the maker writes it. |
| `data[].images` | InlineMediaItem[] | The imagery on file for it, silhouette excluded, since every firearm has one of those. |
| `data[].images[].id` | integer | Autoincrementing image id. |
| `data[].images[].url` | string | Absolute, directly fetchable URL. |
| `data[].images[].kind` | MediaKind | What kind of asset a media row is. |
| `data[].images[].alt` | string \| null | Alt text, where one has been written. Null otherwise, so do not render an empty string. |
| `data[].images[].width` | integer \| null | Pixel width, where it is known. |
| `data[].images[].height` | integer \| null | Pixel height, where it is known. |
| `pagination.page` | number | Current page number |
| `pagination.per_page` | number | Items per page |
| `pagination.total` | number | Total matching records (Builder and above) |

```json
{
  "success": true,
  "data": [
    {
      "id": "ak-47",
      "name": "AK-47",
      "images": [
        {
          "id": 2572,
          "url": "https://api.gunspec.io/v1/firearms/ak-47/images/2572",
          "kind": "silhouette",
          "alt": "AK-47 right profile",
          "width": 1536,
          "height": 1024
        }
      ]
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "per_page": 20,
    "total": 9162,
    "totalPages": 459
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/media' \
  --header 'X-API-Key: your_key'
```

## All media

`GET /v1/firearms/{id}/media`

Auth: API key required  
Tier: Explorer+

One resource for every kind of asset a firearm has, from line-art silhouette and generated render to photograph, schematic and 3D model, returned in a single shape.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | Resource slug ID |

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `kind` | string | no | Return only one kind of asset |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | MediaItem[] | The response payload. |
| `data[].id` | integer \| null | Null for assets stored as a column rather than a row (the 3D model). |
| `data[].kind` | MediaKind | What kind of asset a media row is. |
| `data[].mimeType` | string \| null | Media type of the bytes, e.g. `image/png`. |
| `data[].width` | integer \| null | Pixel width, where it is known. |
| `data[].height` | integer \| null | Pixel height, where it is known. |
| `data[].alt` | string \| null | Alt text, where one has been written. Null otherwise, so do not render an empty string. |
| `data[].url` | string | Absolute, directly fetchable URL. |
| `data[].sizes` | object | Per-size URLs. Absent on assets we link rather than host. |
| `data[].sizes.full` | string | URL of the asset at full size. |
| `data[].sizes.display` | string | URL of the mid-size rendition, for a detail page. |
| `data[].sizes.thumb` | string | URL of the thumbnail rendition, for a list or grid. |
| `data[].credit` | object | Attribution required to reuse the asset. Populated wherever the catalogue records it. |
| `data[].credit.source` | string \| null | Where the record came from: a curated entry, an inference, or an import. |
| `data[].credit.author` | string \| null | Who made the asset, where it is credited. |
| `data[].credit.sourceUrl` | string \| null | Page the asset or document was taken from. |
| `data[].credit.license` | string \| null | Licence the asset is held under, where one is recorded. |

```json
{
  "success": true,
  "data": [
    {
      "id": 2572,
      "kind": "silhouette",
      "mimeType": "image/png",
      "width": 1024,
      "height": 1024,
      "alt": "AK-47, left profile",
      "url": "https://api.gunspec.io/v1/firearms/ak-47/media/2572",
      "sizes": {
        "full": "string",
        "display": "string",
        "thumb": "string"
      },
      "credit": {
        "source": "Wikimedia Commons",
        "author": "Askild Antonsen",
        "sourceUrl": null,
        "license": "CC BY 2.0"
      }
    }
  ]
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 404 | `NOT_FOUND` | Resource not found |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- Available on **Explorer** and above. The catalogue and its credits are free; the bytes are Builder.
- Every `url` is absolute. You will never receive a bare storage key or an internal path.
- Use `?kind=` to fetch just what you need, e.g. `?kind=render` for marketing imagery or `?kind=silhouette` for UI icons.
- The `credit` block is the attribution required to republish an image. Where `license` is set, honour it.
- A firearm may have no assets of a given kind; the array is simply shorter, never an error.

### Example

```bash
# Everything this firearm has
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/ak-47/media' \
  --header 'X-API-Key: your_key'

# Just the renders
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/ak-47/media?kind=render' \
  --header 'X-API-Key: your_key'
```

## One asset

`GET /v1/firearms/{id}/media/{selector}`

Auth: API key required  
Tier: Builder+

Fetch a single asset addressed either by **kind** (`silhouette`, `render`, `photo`, `schematic`, `model`) or by the numeric `id` from the list.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | Resource slug ID |
| `selector` | unknown | yes | Which asset: a **kind**, which resolves to the primary asset of that kind, or the numeric `id` of one row from `/v1/firearms/{id}/media`. Addressing by kind means never having to list first just to learn an id. |

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `format` | string | no | Bytes, an inline data URI, or metadata only |
| `size` | string | no | Pre-rendered derivative; falls back to the original when absent |
| `stroke_width` | integer | no | Silhouettes only: inject a stroke so line art reads on any background |
| `stroke_color` | string | no | Silhouettes only: stroke colour. Any CSS colour the browser understands; quotes, angle brackets, ampersands and backslashes are refused, because the value is written into an SVG attribute |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | MediaItem | One asset belonging to a firearm. Every kind, whether silhouette, render, photo, schematic or 3D model, is returned in this same shape, and `url` is always absolute and directly fetchable. |
| `data.id` | integer \| null | Null for assets stored as a column rather than a row (the 3D model). |
| `data.kind` | MediaKind | What kind of asset a media row is. |
| `data.mimeType` | string \| null | Media type of the bytes, e.g. `image/png`. |
| `data.width` | integer \| null | Pixel width, where it is known. |
| `data.height` | integer \| null | Pixel height, where it is known. |
| `data.alt` | string \| null | Alt text, where one has been written. Null otherwise, so do not render an empty string. |
| `data.url` | string | Absolute, directly fetchable URL. |
| `data.sizes` | object | Per-size URLs. Absent on assets we link rather than host. |
| `data.sizes.full` | string | URL of the asset at full size. |
| `data.sizes.display` | string | URL of the mid-size rendition, for a detail page. |
| `data.sizes.thumb` | string | URL of the thumbnail rendition, for a list or grid. |
| `data.credit` | object | Attribution required to reuse the asset. Populated wherever the catalogue records it. |
| `data.credit.source` | string \| null | Where the record came from: a curated entry, an inference, or an import. |
| `data.credit.author` | string \| null | Who made the asset, where it is credited. |
| `data.credit.sourceUrl` | string \| null | Page the asset or document was taken from. |
| `data.credit.license` | string \| null | Licence the asset is held under, where one is recorded. |

```json
// format=datauri: embed with no second request
{
  "success": true,
  "data": {
    "id": 2572,
    "kind": "render",
    "mimeType": "image/webp",
    "width": 1536, "height": 1024,
    "alt": "AK-47 assault rifle, left-side profile render",
    "url": "https://api.gunspec.io/v1/firearms/ak-47/media/2572",
    "credit": {
      "source": "AI generated (Azure AI Foundry gpt-image-2)",
      "author": "GunSpec", "sourceUrl": null, "license": "GunSpec generated"
    },
    "format": "datauri",
    "dataUri": "data:image/webp;base64,UklGRi..."
  }
}

// format=raw (default)
// Content-Type: image/png
// Cache-Control: public, max-age=86400, immutable
// ETag: "a1b2c3..."
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 404 | `NOT_FOUND` | Resource not found |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- Requires **Builder** tier or higher. Listing media and its credits is Explorer.
- Address by kind (`/media/silhouette`) to skip the listing call entirely. This is the fastest way to pull an asset when you only know the firearm.
- `size=thumb` is typically 10-20x smaller than `full`: on the AK-47 render that is 12KB against 528KB. Use it for grids, lists and HUD icons.
- `format=datauri` without an explicit `size` automatically picks the largest variant under the 256KB cap, so embedding just works. Pass an explicit `size` if you need a specific one.
- Assets we link rather than host, such as schematics, 3D models and externally sourced photos, answer with a 302 to their location.
- `stroke_width` and `stroke_color` apply to silhouettes only and are ignored elsewhere.

### Example

```bash
# The SVG silhouette, one request, no lookup
curl --request GET \
  --output ak-47.svg \
  --url 'https://api.gunspec.io/v1/firearms/ak-47/media/silhouette' \
  --header 'X-API-Key: your_key'

# A render thumbnail (12KB, not 528KB)
curl --request GET \
  --output ak-47-thumb.webp \
  --url 'https://api.gunspec.io/v1/firearms/ak-47/media/render?size=thumb' \
  --header 'X-API-Key: your_key'

# Embeddable data URI, size chosen for you
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/ak-47/media/render?format=datauri' \
  --header 'X-API-Key: your_key'

# White HUD icon from the line art
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/ak-47/media/silhouette?format=datauri&stroke_width=6&stroke_color=white' \
  --header 'X-API-Key: your_key'
```

## Get Schematics

`GET /v1/firearms/{id}/schematics`

Auth: API key required  
Tier: Studio+

Returns exploded-view and technical schematic drawings for a firearm.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | Firearm slug |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | FirearmSchematic[] | The response payload. |
| `data[].id` | integer | Autoincrementing schematic id. |
| `data[].firearmId` | string | Slug of the firearm this row belongs to. |
| `data[].title` | string | What the document is called. |
| `data[].type` | SchematicType | What a schematic document is. |
| `data[].url` | string | Absolute, directly fetchable URL. |
| `data[].format` | string \| null | File format, e.g. `pdf`. |
| `data[].version` | string \| null | Revision of the document, where the publisher versions it. |
| `data[].manufacturer` | string \| null | Publisher of the document, often but not always the maker. |
| `data[].source` | string \| null | Where the document came from. |
| `data[].sourceUrl` | string \| null | Page the asset or document was taken from. |
| `data[].author` | string \| null | Who made the asset, where it is credited. |
| `data[].license` | string \| null | Licence the asset is held under, where one is recorded. |
| `data[].createdAt` | string | When the record was first added, `YYYY-MM-DD HH:MM:SS` in UTC. |

```json
{
  "success": true,
  "data": [
    {
      "id": 12,
      "firearmId": "glock-17-gen5",
      "title": "Exploded view",
      "type": "blueprint",
      "url": "https://assets.gunspec.io/firearms/schematics/glock-17-gen5-exploded.pdf",
      "format": "pdf",
      "version": null,
      "manufacturer": "Glock Ges.m.b.H.",
      "source": null,
      "sourceUrl": null,
      "author": null,
      "license": null,
      "createdAt": "2026-05-26 12:36:27"
    }
  ]
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 404 | `NOT_FOUND` | Resource not found |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- Requires **Studio** tier or higher. The same documents are embedded in the firearm detail response for Studio+ keys.
- `url` is a path on the assets CDN. Prefix it with https://assets.gunspec.io to fetch the file.
- Only Studio+ keys receive these URLs, and paths include an unguessable segment, so documents cannot be enumerated. The files themselves are served from the public CDN, so treat the URL as a shareable link, not a secret.
- Reused documents carry source / author / license. Honour the license when you redistribute.

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/daniel-defense-mk18-riii/schematics' \
  --header 'X-API-Key: your_key'
```

## Get Adoption Records

`GET /v1/firearms/{id}/users`

Auth: API key required  
Tier: Explorer+

Returns the military and law-enforcement operators recorded as using this firearm.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | Resource slug ID |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | FirearmUser[] | The response payload. |
| `data[].id` | integer | Autoincrementing row id. |
| `data[].firearmId` | string | Slug of the firearm this row belongs to. |
| `data[].userName` | string | The force, agency or organisation that adopted it. |
| `data[].userType` | string \| null | What kind of user: `military`, `police`, `civilian`. |
| `data[].countryCode` | string \| null | ISO 3166-1 alpha-2 code of the user's country. |
| `data[].adoptedYear` | integer \| null | Year of adoption, where it is known. |
| `data[].designation` | string \| null | What that user calls it, where it differs from the maker's name. |

```json
{
  "success": true,
  "data": [
    {
      "id": 1,
      "firearmId": "glock-17-gen5",
      "userName": "Austrian Armed Forces",
      "userType": "military",
      "countryCode": "AT",
      "adoptedYear": 1982,
      "designation": "Pistole 80"
    }
  ]
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 404 | `NOT_FOUND` | Resource not found |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/glock-17-gen5/users' \
  --header 'X-API-Key: your_key'
```

## Silhouette SVG

`GET /v1/firearms/{id}/silhouette`

Auth: API key required  
Tier: Builder+

Returns the scalable line-art silhouette for a firearm, sized to its real-world dimensions.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | Resource slug ID |

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `format` | string | no | An SVG file (`raw`; `svg` is accepted as the same thing, the name SDK 0.1.x used), an inline data URI, or the SVG source as JSON |
| `stroke_width` | integer | no | Inject a stroke so the line art reads on any background. 0 leaves it unstroked; anything above 20 is clamped to 20 |
| `stroke_color` | string | no | Colour of that stroke. Any CSS colour the browser understands: a name, a hex value, `rgb()`, `oklch()`, `color-mix()`. Quotes, angle brackets, ampersands and backslashes are refused, because the value is written into an SVG attribute |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object | The response payload. |
| `data.id` | string | Slug of the firearm. |
| `data.name` | string | Display name, as the maker writes it. |
| `data.slug` | string | Slug of the line-art file, which is the firearm slug. |
| `data.format` | string | Which JSON form this is: `datauri` or `svg`. |
| `data.mimeType` | string | Media type of the bytes, e.g. `image/png`. |
| `data.dataUri` | string | Present when `format=datauri`: the SVG inlined as an RFC 2397 data URI. |
| `data.svg` | string | Present when `format=json`: the SVG source, for parsing or manipulating in code. |

```json
// format=datauri: embed directly in <img> or CSS
{
  "success": true,
  "data": {
    "id": "ak-47",
    "name": "AK-47",
    "slug": "ak-47",
    "format": "datauri",
    "mimeType": "image/svg+xml",
    "dataUri": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0i..."
  }
}

// format=json - SVG source for manipulation
{
  "success": true,
  "data": {
    "id": "ak-47",
    "name": "AK-47",
    "slug": "ak-47",
    "format": "svg",
    "mimeType": "image/svg+xml",
    "svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" ...>...</svg>"
  }
}

// format=raw (default) - raw SVG file
// Content-Type: image/svg+xml
// Cache-Control: public, max-age=86400, immutable
// Content-Disposition: inline; filename="ak-47.svg"
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 404 | `NOT_FOUND` | Resource not found |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- Requires **Builder** tier or higher.
- Three formats: `raw` returns the SVG file directly, `datauri` returns a JSON-wrapped base64 data URI, `json` returns the SVG XML as a JSON string.
- `datauri` is ideal for embedding: drop the value straight into `<img src="">`, CSS `background-image: url(...)`, or Unity/Godot sprite loaders.
- `json` is ideal for DOM injection (`innerHTML = data.svg`) or programmatic SVG manipulation (changing colors, extracting paths).
- SVGs are high-quality vector illustrations that scale to any resolution without quality loss.
- Silhouettes cover 4,500+ firearms including all major platforms.
- `stroke_width` adds an outline stroke to every SVG path at the requested width (1-20px at native SVG resolution). Use values of 4-8 for small HUD icons and inventory thumbnails where thin lines would otherwise disappear. Omit for full-size renders.
- `stroke_color` defaults to `black`. Set to `white` for dark backgrounds (e.g. game HUDs). URL-encode hex values: `%23ff0000` for `#ff0000`.

### Example

```bash
# Raw SVG file (default)
curl --request GET \
  --output ak-47.svg \
  --url 'https://api.gunspec.io/v1/firearms/ak-47/silhouette' \
  --header 'X-API-Key: your_key'

# Data URI for embedding
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/ak-47/silhouette?format=datauri' \
  --header 'X-API-Key: your_key'

# SVG source as JSON string
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/ak-47/silhouette?format=json' \
  --header 'X-API-Key: your_key'

# Thicker lines for HUD icons (stroke_width=6)
curl --request GET \
  --output ak-47-thick.svg \
  --url 'https://api.gunspec.io/v1/firearms/ak-47/silhouette?stroke_width=6' \
  --header 'X-API-Key: your_key'

# White strokes for dark HUD backgrounds
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/ak-47/silhouette?format=datauri&stroke_width=6&stroke_color=white' \
  --header 'X-API-Key: your_key'
```

## Get 3D model

`GET /v1/firearms/{id}/model`

Auth: API key required  
Tier: Builder+

Returns the glTF binary (`.glb`) for a firearm, ready to drop into a three.js or Babylon scene. Models are Draco-free but meshopt-compressed and carry WebP textures, so a loader needs `EXT_meshopt_compression`, `EXT_texture_webp` and `KHR_mesh_quantization`: all three are standard glTF extensions. Models we host on the asset CDN answer with a 302 to their location, the same as the image routes; models uploaded to our own bucket stream from here. 404 when the firearm has no model. use `has_3d_model=true` on /v1/firearms to list the ones that do.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | Firearm slug |

### Response 200 (model/gltf-binary)

```json
"string"
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 404 | `NOT_FOUND` | Resource not found |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/ak-47/model' \
  --header 'X-API-Key: your_key'
```

## Game Profile

`GET /v1/firearms/{id}/game-profile`

Auth: API key required  
Tier: Builder+

Returns a firearm game stats together with the archetype it classifies as, and the stats that make it strong or weak.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | Resource slug ID |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | GameProfile | Game stats for a firearm plus its derived archetype classification. |
| `data.id` | string | Slug of the firearm this profile is for. |
| `data.name` | string | Display name, as the maker writes it. |
| `data.gameDamage` | integer \| null | Editorial game statistic, 0-100. **Not a measured figure.** These are balance numbers for game use and are not derived from the ballistics above. |
| `data.gameAccuracy` | integer \| null | Editorial game statistic, 0-100. Not a measured figure. |
| `data.gameRange` | integer \| null | Editorial game statistic, 0-100. Not a measured figure. |
| `data.gameFireRate` | integer \| null | Editorial game statistic, 0-100. Not a measured figure. |
| `data.gameMobility` | integer \| null | Editorial game statistic, 0-100. Not a measured figure. |
| `data.gameRecoilControl` | integer \| null | Editorial game statistic, 0-100. Not a measured figure. |
| `data.gameReloadSpeed` | integer \| null | Editorial game statistic, 0-100. Not a measured figure. |
| `data.gameConcealment` | integer \| null | Editorial game statistic, 0-100. Not a measured figure. |
| `data.archetype` | GameArchetype | The role a firearm's game profile assigns it. |
| `data.strengths` | string[] | The statistics this firearm scores highest on. |
| `data.weaknesses` | string[] | The statistics it scores lowest on. |

```json
{
  "success": true,
  "data": {
    "id": "glock-17-gen5",
    "name": "Glock 17 Gen5",
    "gameDamage": 45,
    "gameAccuracy": 70,
    "gameRange": 35,
    "gameFireRate": 55,
    "gameMobility": 85,
    "gameRecoilControl": 65,
    "gameReloadSpeed": 75,
    "gameConcealment": 80,
    "archetype": "sniper",
    "strengths": [
      "mobility",
      "concealment"
    ],
    "weaknesses": [
      "range"
    ]
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 404 | `NOT_FOUND` | Resource not found |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- Requires **Builder** tier or higher.
- Archetype is computed from weighted stat analysis across all 8 categories.
- Strengths list stats scoring above 70. Weaknesses list stats scoring below 35.
- Uses the same live stats as `/v1/firearms/{id}/game-stats`.

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/glock-17-gen5/game-profile' \
  --header 'X-API-Key: your_key'
```

## Dimensions

`GET /v1/firearms/{id}/dimensions`

Auth: API key required  
Tier: Builder+

Returns the physical measurements of a firearm in metric and imperial units.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | Resource slug ID |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | FirearmDimensions | Physical dimensions in both metric and imperial units. |
| `data.id` | string | Slug of the firearm. |
| `data.name` | string | Display name, as the maker writes it. |
| `data.metric` | object | Every dimension in millimetres and grams. |
| `data.metric.weightEmptyG` | number \| null | Unloaded weight in grams, without magazine. |
| `data.metric.weightLoadedG` | number \| null | Loaded weight in grams, with a full magazine. |
| `data.metric.overallLengthMm` | number \| null | Overall length in millimetres, stock extended where it folds or collapses. |
| `data.metric.barrelLengthMm` | number \| null | Barrel length in millimetres, measured from the breech face. |
| `data.metric.heightMm` | number \| null | Height in millimetres, including sights and a fitted magazine. |
| `data.metric.widthMm` | number \| null | Width in millimetres at the widest point. |
| `data.metric.foldedLengthMm` | number \| null | Overall length in millimetres with the stock folded or collapsed. Null where it does not. |
| `data.imperial` | object | The same measurements converted to pounds and inches. |
| `data.imperial.weightEmptyLbs` | number \| null | Unloaded weight in pounds, without magazine. Converted from `weightEmptyG`. |
| `data.imperial.weightLoadedLbs` | number \| null | Loaded weight in pounds, with a full magazine. Converted from `weightLoadedG`. |
| `data.imperial.overallLengthIn` | number \| null | Overall length in inches, stock extended where it folds or collapses. Converted from `overallLengthMm`. |
| `data.imperial.barrelLengthIn` | number \| null | Barrel length in inches, measured from the breech face. Converted from `barrelLengthMm`. |
| `data.imperial.heightIn` | number \| null | Height in inches, including sights and a fitted magazine. Converted from `heightMm`. |
| `data.imperial.widthIn` | number \| null | Width in inches at the widest point. Converted from `widthMm`. |
| `data.imperial.foldedLengthIn` | number \| null | Overall length in inches with the stock folded or collapsed. Null where it does not. Converted from `foldedLengthMm`. |

```json
{
  "success": true,
  "data": {
    "id": "glock-17-gen5",
    "name": "Glock 17 Gen5",
    "metric": {
      "weightEmptyG": 625,
      "weightLoadedG": 915,
      "overallLengthMm": 202,
      "barrelLengthMm": 114,
      "heightMm": 139,
      "widthMm": 34,
      "foldedLengthMm": null
    },
    "imperial": {
      "weightEmptyLbs": 1.378,
      "weightLoadedLbs": 2.017,
      "overallLengthIn": 7.953,
      "barrelLengthIn": 4.488,
      "heightIn": 5.472,
      "widthIn": 1.339,
      "foldedLengthIn": null
    }
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 404 | `NOT_FOUND` | Resource not found |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- Requires **Builder** tier or higher.
- Imperial conversions use: 1 mm = 0.03937 in, 1 g = 0.03527 oz.
- Fields return `null` if the dimension is not available for this firearm.

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/glock-17-gen5/dimensions' \
  --header 'X-API-Key: your_key'
```

## Get Family Tree

`GET /v1/firearms/{id}/family-tree`

Auth: API key required  
Tier: Builder+

Walks the parent chain upward and the variant chain downward, returning ancestors (up to 20) and descendants (up to 50) around the given firearm.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | Firearm slug |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | FamilyTree | The firearm plus its ancestor and descendant variants, walked recursively. |
| `data.ancestors` | FamilyMember[] | The parent chain, nearest first, up to 20 deep. |
| `data.ancestors[].id` | string | URL-safe slug identifying the related firearm, e.g. `type-58`. |
| `data.ancestors[].name` | string | Display name, as the maker writes it. |
| `data.ancestors[].manufacturerId` | string \| null | Slug of the manufacturer that makes it. Resolve against `/v1/manufacturers/{id}`. |
| `data.ancestors[].categoryId` | string \| null | Slug of the category it belongs to. Resolve against `/v1/categories`. |
| `data.ancestors[].parentFirearmId` | string \| null | The firearm this one is a variant of, or null when it is not a variant. Variant relationships are what `firearm.variant.updated` webhooks and `/v1/firearms/{id}/variants` follow. |
| `data.ancestors[].variantType` | string \| null | How this record differs from its parent: `optics_ready`, `compact`, `threaded` and so on. Null when the record has no parent. |
| `data.ancestors[].yearIntroduced` | integer \| null | Year it entered production or service. Null where the date is disputed or unknown. |
| `data.ancestors[].status` | FirearmStatus \| null | Production status, one of `in_production`, `discontinued`, `out_of_production`, `in_service`, `limited_production`, `prototype`. Discontinued records are kept deliberately: a rifle built in 2004 wears parts nobody sells today. |
| `data.ancestors[].countryOfOrigin` | string \| null | ISO 3166-1 alpha-2 code of where it was designed, e.g. `AT`. |
| `data.ancestors[].svgLineArtUrl` | string \| null | Line-art silhouette, or null where none has been drawn. |
| `data.ancestors[].updatedAt` | string | When a field we serve last changed. Maintained by the database rather than by whatever wrote the row, so it moves for a corrected specification and does not move for a re-run of the importer. |
| `data.ancestors[].version` | string \| null | Opaque fingerprint of this record. It changes when and only when a field we serve changes, so a client holding the same value already has the record and need not fetch it. Equality is the only supported operation: do not parse it, and do not assume how it is computed. Null means the record has not been written since versioning began, not that it is unchanged. |
| `data.current` | FirearmRecord | A firearm as stored: every specification column we serve, with no nested relations. `FirearmDetail` is this plus them, and `/v1/firearms/{id}/family-tree` returns this shape for `current`, since the walk reads the row and does not load the relations. |
| `data.current.has3dModel` | integer | Integer flag, `1` when a 3D model is on file. Stored as the database holds it rather than as a boolean. |
| `data.current.svgLineArtUrl` | string \| null | Line-art silhouette, or null where none has been drawn. |
| `data.current.model3dUrl` | string \| null | GLB model, or null where none is on file. |
| `data.current.defaultAmmoId` | string \| null | The ammunition load ballistics figures are quoted against, where one is nominated. |
| `data.current.sourceMuzzleVelocityMps` | number \| null | Muzzle velocity in metres per second **exactly as the source stated it**, kept beside our own figure so a reader can see what was quoted and what was derived. |
| `data.current.sourceMuzzleEnergyJ` | number \| null | Muzzle energy in joules exactly as the source stated it. |
| `data.current.sourceEffectiveRangeM` | number \| null | Effective range in metres exactly as the source stated it. |
| `data.current.sourceMaxRangeM` | number \| null | Maximum range in metres exactly as the source stated it. |
| `data.current.ballisticsSource` | string \| null | Where the ballistics figures came from, named in prose. |
| `data.current.ballisticsSourceUrl` | string \| null | URL of the ballistics source, where it is a page rather than a book. |
| `data.current.id` | string | URL-safe slug identifying the firearm, e.g. `glock-17-gen5`. Stable: it is what a mirror keys on. |
| `data.current.name` | string | Display name, as the maker writes it. |
| `data.descendants` | FamilyMember[] | Everything derived from it, breadth-first, up to 50 rows. |
| `data.descendants[].id` | string | URL-safe slug identifying the related firearm, e.g. `type-58`. |
| `data.descendants[].name` | string | Display name, as the maker writes it. |
| `data.descendants[].manufacturerId` | string \| null | Slug of the manufacturer that makes it. Resolve against `/v1/manufacturers/{id}`. |
| `data.descendants[].categoryId` | string \| null | Slug of the category it belongs to. Resolve against `/v1/categories`. |
| `data.descendants[].parentFirearmId` | string \| null | The firearm this one is a variant of, or null when it is not a variant. Variant relationships are what `firearm.variant.updated` webhooks and `/v1/firearms/{id}/variants` follow. |
| `data.descendants[].variantType` | string \| null | How this record differs from its parent: `optics_ready`, `compact`, `threaded` and so on. Null when the record has no parent. |
| `data.descendants[].yearIntroduced` | integer \| null | Year it entered production or service. Null where the date is disputed or unknown. |
| `data.descendants[].status` | FirearmStatus \| null | Production status, one of `in_production`, `discontinued`, `out_of_production`, `in_service`, `limited_production`, `prototype`. Discontinued records are kept deliberately: a rifle built in 2004 wears parts nobody sells today. |
| `data.descendants[].countryOfOrigin` | string \| null | ISO 3166-1 alpha-2 code of where it was designed, e.g. `AT`. |
| `data.descendants[].svgLineArtUrl` | string \| null | Line-art silhouette, or null where none has been drawn. |
| `data.descendants[].updatedAt` | string | When a field we serve last changed. Maintained by the database rather than by whatever wrote the row, so it moves for a corrected specification and does not move for a re-run of the importer. |
| `data.descendants[].version` | string \| null | Opaque fingerprint of this record. It changes when and only when a field we serve changes, so a client holding the same value already has the record and need not fetch it. Equality is the only supported operation: do not parse it, and do not assume how it is computed. Null means the record has not been written since versioning began, not that it is unchanged. |

```json
{
  "success": true,
  "data": {
    "ancestors": [
      {
        "id": "type-58",
        "name": "Type 58",
        "manufacturerId": "north-korean-state-arsenals",
        "categoryId": "assault-rifle",
        "parentFirearmId": "ak-47",
        "variantType": "foreign_production",
        "yearIntroduced": 1958,
        "status": "in_production",
        "countryOfOrigin": "KP",
        "svgLineArtUrl": "https://api.gunspec.io/v1/firearms/type-58/media/silhouette",
        "updatedAt": "2026-09-10 06:42:19",
        "version": "a3f1c2d4e5b6a7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2"
      }
    ],
    "current": {
      "has3dModel": 0,
      "svgLineArtUrl": "https://api.gunspec.io/v1/firearms/glock-17-gen5/media/silhouette",
      "model3dUrl": null,
      "defaultAmmoId": "m882",
      "sourceMuzzleVelocityMps": 375,
      "sourceMuzzleEnergyJ": 565,
      "sourceEffectiveRangeM": 50,
      "sourceMaxRangeM": 1800,
      "ballisticsSource": "Manufacturer specification sheet",
      "ballisticsSourceUrl": "https://eu.glock.com/en/pistols/g17",
      "id": "glock-17-gen5",
      "name": "Glock 17 Gen5",
      "manufacturerId": "glock",
      "categoryId": "pistol",
      "parentFirearmId": "glock-17",
      "variantType": "generation",
      "yearIntroduced": 2017,
      "yearDiscontinued": null,
      "status": "in_production",
      "countryOfOrigin": "AT",
      "weightEmptyG": 625,
      "weightLoadedG": 905,
      "overallLengthMm": 204,
      "barrelLengthMm": 114,
      "heightMm": 139,
      "widthMm": 34,
      "sightRadiusMm": 165,
      "actionType": "short_recoil",
      "firingMechanism": "striker-fired",
      "triggerType": "safe-action",
      "triggerPullN": 28,
      "magazineCapacity": 17,
      "magazineType": "detachable-box",
      "muzzleVelocityMps": 375,
      "muzzleEnergyJ": 570,
      "effectiveRangeM": 50,
      "maxRangeM": null,
      "rateOfFireRpm": null,
      "barrelRifling": "polygonal",
      "riflingTwistMm": 250,
      "numberOfGrooves": null,
      "frameMaterial": "polymer",
      "slideMaterial": "steel",
      "barrelMaterial": "steel",
      "stockMaterial": null,
      "finish": "nDLC",
      "safetyMechanisms": [
        "manual_safety_selector",
        "half_cock_notch"
      ],
      "features": [
        "collapsible_stock",
        "accessory_rail"
      ],
      "feedSystems": [
        "detachable_box_magazine"
      ],
      "gameDamage": null,
      "gameAccuracy": null,
      "gameRange": null,
      "gameFireRate": null,
      "gameMobility": null,
      "gameRecoilControl": null,
      "gameReloadSpeed": null,
      "gameConcealment": null,
      "description": null,
      "notes": null,
      "designer": "Mikhail Kalashnikov",
      "foldedLengthMm": 490,
      "alternateNames": [
        "Avtomat Kalashnikova",
        "Type 56",
        "Kalash"
      ],
      "firingModes": [
        "semi_automatic",
        "full_automatic"
      ],
      "conflicts": [
        {
          "name": "Vietnam War",
          "years": "1955-1975",
          "sides": [
            "North Vietnam / Viet Cong",
            "South Vietnam / United States"
          ]
        }
      ],
      "productionNumbers": {
        "estimated_total": 75000000,
        "production_years": "1947-1975",
        "notes": "Approximately 75 million AK-47 type rifles produced."
      },
      "lore": "The AK-47 is so iconic it appears on the national flag of Mozambique.",
      "sources": [
        "string"
      ],
      "dataConfidence": 0.95,
      "createdAt": "2025-01-15 12:00:00",
      "updatedAt": "2026-09-10 06:42:19",
      "version": "a3f1c2d4e5b6a7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2",
      "provenance": {
        "sources": [
          "https://www.glock.com/en/products/pistols/g17-gen5"
        ],
        "sourceKinds": [
          {
            "url": "https://www.glock.com/en/products/pistols/g17-gen5",
            "kind": "manufacturer"
          }
        ],
        "bestSourceKind": "manufacturer",
        "dataConfidence": 0.95,
        "verifiedAt": "2026-09-08",
        "verifiedFields": [
          "barrel_length_mm",
          "weight_empty_g"
        ],
        "specSource": null,
        "updatedAt": "2026-09-08 04:10:22",
        "version": "ba5c1d9e06c279a5"
      }
    },
    "descendants": [
      {
        "id": "type-58",
        "name": "Type 58",
        "manufacturerId": "north-korean-state-arsenals",
        "categoryId": "assault-rifle",
        "parentFirearmId": "ak-47",
        "variantType": "foreign_production",
        "yearIntroduced": 1958,
        "status": "in_production",
        "countryOfOrigin": "KP",
        "svgLineArtUrl": "https://api.gunspec.io/v1/firearms/type-58/media/silhouette",
        "updatedAt": "2026-09-10 06:42:19",
        "version": "a3f1c2d4e5b6a7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2"
      }
    ]
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 404 | `NOT_FOUND` | Resource not found |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- Requires **Builder** tier or higher.
- Ancestors are returned root-first (oldest ancestor at index 0).
- Descendants are returned in a flat array, depth-first.

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/akm/family-tree' \
  --header 'X-API-Key: your_key'
```

## Find Similar

`GET /v1/firearms/{id}/similar`

Auth: API key required  
Tier: Explorer+

Returns the 10 most similar firearms in the same category, scored on shared calibers, manufacturer, action type, weight, barrel length, year, and capacity. Each result inlines its `images` array so a "you may also like" strip can be rendered from this one call.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | Resource slug ID |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | SimilarFirearm[] | The response payload. |
| `data[].id` | string | Slug of the similar firearm. |
| `data[].name` | string | Display name, as the maker writes it. |
| `data[].score` | number | How alike the two records are, 0 to 1. Computed from the specifications, not from what people browse together. |
| `data[].manufacturerId` | string \| null | Slug of the manufacturer that makes it. Resolve against `/v1/manufacturers/{id}`. |
| `data[].categoryId` | string \| null | Slug of the category it belongs to. Resolve against `/v1/categories`. |
| `data[].countryOfOrigin` | string \| null | ISO 3166-1 alpha-2 code of where it was designed, e.g. `AT`. |
| `data[].yearIntroduced` | integer \| null | Year it entered production or service. Null where the date is disputed or unknown. |
| `data[].status` | FirearmStatus \| null | Production status, one of `in_production`, `discontinued`, `out_of_production`, `in_service`, `limited_production`, `prototype`. Discontinued records are kept deliberately: a rifle built in 2004 wears parts nobody sells today. |
| `data[].svgLineArtUrl` | string \| null | Line-art silhouette, or null where none has been drawn. |
| `data[].actionType` | string \| null | How the action cycles: `short_recoil`, `gas_operated`, `blowback`, `bolt_action` and so on. An open vocabulary: the values in use are published on the schema as `x-gunspec-vocabulary`, and `GET /v1/firearms/action-types` is the live list. |
| `data[].images` | InlineMediaItem[] | Every image this firearm has, silhouette first. Empty when none are on file. |
| `data[].images[].id` | integer | Autoincrementing image id. |
| `data[].images[].url` | string | Absolute, directly fetchable URL. |
| `data[].images[].kind` | MediaKind | What kind of asset a media row is. |
| `data[].images[].alt` | string \| null | Alt text, where one has been written. Null otherwise, so do not render an empty string. |
| `data[].images[].width` | integer \| null | Pixel width, where it is known. |
| `data[].images[].height` | integer \| null | Pixel height, where it is known. |

```json
{
  "success": true,
  "data": [
    {
      "id": "sig-sauer-p320-full-size",
      "name": "SIG Sauer P320",
      "score": 0.82,
      "manufacturerId": "sig-sauer",
      "categoryId": "pistol",
      "countryOfOrigin": "US",
      "yearIntroduced": 2014,
      "status": "in_production",
      "svgLineArtUrl": null,
      "actionType": "short_recoil",
      "images": [
        {
          "id": 2572,
          "url": "https://api.gunspec.io/v1/firearms/ak-47/images/2572",
          "kind": "silhouette",
          "alt": "AK-47 right profile",
          "width": 1536,
          "height": 1024
        }
      ]
    }
  ]
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 404 | `NOT_FOUND` | Resource not found |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- Requires **Explorer** tier or higher.
- Returns at most 10 results, sorted by similarity score descending.
- Score factors: shared calibers (0.20), action type (0.15), weight proximity, barrel length, era, magazine capacity, manufacturer.
- Each result inlines its `images` array, so a related-products strip needs no follow-up request. For credits, licensing, or data-URI encoding, call `/v1/firearms/{id}/media`.

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/glock-17-gen5/similar' \
  --header 'X-API-Key: your_key'
```

## Adoption Map

`GET /v1/firearms/{id}/adoption-map`

Auth: API key required  
Tier: Studio+

Returns which countries adopted a firearm, with the operators, adoption years, and local designations grouped per country.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | Resource slug ID |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | AdoptionMap | Military and law-enforcement adoption of a firearm, grouped by country. |
| `data.firearmId` | string | Slug of the firearm this row belongs to. |
| `data.firearmName` | string | Display name of the firearm the map is for. |
| `data.countries` | object[] | Every country recorded as having adopted it. |
| `data.countries[].code` | string \| null | ISO 3166-1 alpha-2 code of the country. Null where the operator country is unrecorded. |
| `data.countries[].users` | object[] | The organisations in that country recorded as operating the firearm. |

```json
{
  "success": true,
  "data": {
    "firearmId": "ak-47",
    "firearmName": "AK-47",
    "countries": [
      {
        "code": "RU",
        "users": [
          {
            "name": "Russian Ground Forces",
            "type": "military",
            "year": 1949,
            "designation": "AK"
          }
        ]
      }
    ]
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 404 | `NOT_FOUND` | Resource not found |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- Requires **Studio** tier or higher.
- Countries are sorted alphabetically by country code.
- Ideal for rendering choropleth or marker-based adoption maps.

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/glock-17-gen5/adoption-map' \
  --header 'X-API-Key: your_key'
```

## Random Firearm

`GET /v1/firearms/random`

Auth: API key required  
Tier: Explorer+

Returns one firearm at random, optionally constrained by category or country. Responses are sent with `Cache-Control: no-store` so every call is a fresh draw.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `category` | string | no | Restrict the draw to a category slug |
| `country` | string | no | Restrict the draw to an ISO country code |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object | The response payload. |
| `data.id` | string | Slug of the drawn firearm. |
| `data.name` | string | Display name, as the maker writes it. |
| `data.manufacturer_id` | string \| null | Slug of the manufacturer. Null where the maker is unrecorded. |
| `data.category_id` | string \| null | Slug of the category the firearm belongs to. |
| `data.year_introduced` | integer \| null | Year the model was introduced. |
| `data.status` | string \| null | Production status, one of `in_production`, `discontinued`, `out_of_production`, `in_service`, `limited_production`, `prototype`. Discontinued records are kept deliberately: a rifle built in 2004 wears parts nobody sells today. |
| `data.country_of_origin` | string \| null | ISO 3166-1 alpha-2 code of the country of origin. |
| `data.action_type` | string \| null | Operating action as stored, underscored, e.g. `short_recoil`, `bolt_action`, `gas_operated`. The full vocabulary is `/v1/firearms/action-types`; a hyphenated value matches nothing. |
| `data.weight_empty_g` | number \| null | Unloaded weight in grams. |
| `data.barrel_length_mm` | number \| null | Barrel length in millimetres. |

```json
{
  "success": true,
  "data": {
    "id": "glock-17-gen5",
    "name": "Glock 17 Gen5",
    "manufacturer_id": "glock",
    "category_id": "pistol",
    "year_introduced": 2017,
    "status": "in_production",
    "country_of_origin": "AT",
    "action_type": "short_recoil",
    "weight_empty_g": 625,
    "barrel_length_mm": 114
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 404 | `NOT_FOUND` | Resource not found |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- Each request returns a different random firearm.
- Combine with category or country filters to narrow the pool.

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/random?category=pistol' \
  --header 'X-API-Key: your_key'
```

## Top Firearms

`GET /v1/firearms/top`

Auth: API key required  
Tier: Builder+

Returns the firearms that lead a single measurable stat. Records missing the underlying measurement are excluded rather than ranked as zero.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `stat` | string | yes | Which superlative to rank by |
| `category` | string | no | Restrict the ranking to a category slug |
| `limit` | integer | no | How many to return |
| `If-None-Match` | string | no | The `ETag` from a previous response. If the body would be byte-identical, the API answers `304 Not Modified` with no body. A 304 counts toward the per-minute rate limit but not the daily request allowance. |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | TopFirearm[] | The response payload. |
| `data[].id` | string | Slug of the firearm. |
| `data[].name` | string | Display name, as the maker writes it. |
| `data[].manufacturerId` | string \| null | Slug of the manufacturer that makes it. Resolve against `/v1/manufacturers/{id}`. |
| `data[].categoryId` | string \| null | Slug of the category it belongs to. Resolve against `/v1/categories`. |
| `data[].value` | number \| null | The figure this ranking is by, in the unit of whichever statistic was requested. |
| `data[].unit` | string | One of g, m, rpm, mm, rounds, J |

```json
{
  "success": true,
  "data": [
    {
      "id": "glock-17-gen5",
      "name": "Glock 17 Gen5",
      "manufacturerId": "glock",
      "categoryId": "pistol",
      "value": 625,
      "unit": "g"
    }
  ]
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- Requires **Builder** tier or higher.
- Valid stats: `lightest`, `heaviest`, `longest-range`, `highest-rof`, `most-compact`, `highest-capacity`, `most-powerful`.
- The `value` field unit depends on the stat (grams for weight, meters for range, RPM for ROF, rounds for capacity, joules for power).

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/top?stat=lightest&category=pistol&limit=5' \
  --header 'X-API-Key: your_key'
```

## Head to Head

`GET /v1/firearms/head-to-head`

Auth: API key required  
Tier: Builder+

Returns a per-stat verdict between exactly two firearms, plus the win/loss/draw tally across all compared stats.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `a` | string | yes | First firearm slug |
| `b` | string | yes | Second firearm slug |
| `If-None-Match` | string | no | The `ETag` from a previous response. If the body would be byte-identical, the API answers `304 Not Modified` with no body. A 304 counts toward the per-minute rate limit but not the daily request allowance. |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object | The response payload. |
| `data.a` | HeadToHeadFirearm | One of the two compared firearms, as selected: the sixteen columns the verdicts are computed from, **snake_case**, under their database names. |
| `data.a.id` | string | URL-safe slug identifying the firearm. Stable: it is what a mirror keys on. |
| `data.a.name` | string | Display name, as the maker writes it. |
| `data.a.manufacturer_id` | string \| null | Slug of the manufacturer. `manufacturerId` on the camelCase endpoints. |
| `data.a.category_id` | string \| null | Slug of the category. `categoryId` on the camelCase endpoints. |
| `data.a.weight_empty_g` | number \| null | Unloaded weight in grams, without magazine. |
| `data.a.overall_length_mm` | number \| null | Overall length in millimetres. |
| `data.a.barrel_length_mm` | number \| null | Barrel length in millimetres, measured from the breech face. |
| `data.a.magazine_capacity` | integer \| null | Standard magazine capacity, in rounds. |
| `data.a.muzzle_velocity_mps` | number \| null | Muzzle velocity in metres per second, with the reference load. |
| `data.a.muzzle_energy_j` | number \| null | Muzzle energy in joules, with the reference load. |
| `data.a.effective_range_m` | number \| null | Effective range in metres. |
| `data.a.rate_of_fire_rpm` | number \| null | Cyclic rate of fire in rounds per minute; null for a semi-automatic or manual action. |
| `data.b` | HeadToHeadFirearm | One of the two compared firearms, as selected: the sixteen columns the verdicts are computed from, **snake_case**, under their database names. |
| `data.b.id` | string | URL-safe slug identifying the firearm. Stable: it is what a mirror keys on. |
| `data.b.name` | string | Display name, as the maker writes it. |
| `data.b.manufacturer_id` | string \| null | Slug of the manufacturer. `manufacturerId` on the camelCase endpoints. |
| `data.b.category_id` | string \| null | Slug of the category. `categoryId` on the camelCase endpoints. |
| `data.b.weight_empty_g` | number \| null | Unloaded weight in grams, without magazine. |
| `data.b.overall_length_mm` | number \| null | Overall length in millimetres. |
| `data.b.barrel_length_mm` | number \| null | Barrel length in millimetres, measured from the breech face. |
| `data.b.magazine_capacity` | integer \| null | Standard magazine capacity, in rounds. |
| `data.b.muzzle_velocity_mps` | number \| null | Muzzle velocity in metres per second, with the reference load. |
| `data.b.muzzle_energy_j` | number \| null | Muzzle energy in joules, with the reference load. |
| `data.b.effective_range_m` | number \| null | Effective range in metres. |
| `data.b.rate_of_fire_rpm` | number \| null | Cyclic rate of fire in rounds per minute; null for a semi-automatic or manual action. |
| `data.verdicts` | object | One verdict per compared stat, keyed by the stat's camelCase name (`weightEmptyG`, `overallLengthMm`, `barrelLengthMm`, `magazineCapacity`, `muzzleVelocityMps`, `muzzleEnergyJ`, `effectiveRangeM`, `rateOfFireRpm`). A stat neither firearm carries is omitted, so the key set is the stats that could be compared. |

```json
{
  "success": true,
  "data": {
    "a": {
      "id": "glock-17-gen5",
      "name": "Glock 17 Gen5",
      "manufacturer_id": "glock",
      "category_id": "pistol",
      "weight_empty_g": 625,
      "overall_length_mm": 202,
      "barrel_length_mm": 114,
      "magazine_capacity": 17,
      "muzzle_velocity_mps": 375,
      "muzzle_energy_j": 500,
      "effective_range_m": 50,
      "rate_of_fire_rpm": null,
      "year_introduced": 2017,
      "status": "in_production",
      "country_of_origin": "AT",
      "action_type": "short_recoil"
    },
    "b": {
      "id": "glock-17-gen5",
      "name": "Glock 17 Gen5",
      "manufacturer_id": "glock",
      "category_id": "pistol",
      "weight_empty_g": 625,
      "overall_length_mm": 202,
      "barrel_length_mm": 114,
      "magazine_capacity": 17,
      "muzzle_velocity_mps": 375,
      "muzzle_energy_j": 500,
      "effective_range_m": 50,
      "rate_of_fire_rpm": null,
      "year_introduced": 2017,
      "status": "in_production",
      "country_of_origin": "AT",
      "action_type": "short_recoil"
    },
    "verdicts": {
      "weightEmptyG": {
        "winner": "a",
        "a": 625,
        "b": 833,
        "better": "lighter"
      },
      "magazineCapacity": {
        "winner": "b",
        "a": 17,
        "b": 21,
        "better": "higher"
      }
    }
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 404 | `NOT_FOUND` | Resource not found |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- Requires **Builder** tier or higher.
- `verdicts` is an object keyed by the camelCase stat name (`weightEmptyG`, `magazineCapacity` ...), not an array. Each entry carries `winner` (`a`, `b` or `draw`), both values, and `better`, which says what winning means for that stat (`lighter`, `longer`, `higher` ...).
- A stat neither firearm carries is omitted; a firearm that carries a value beats one that does not.
- `a` and `b` are the sixteen columns the verdicts are computed from, under their **snake_case** database names, the same rule as `/v1/firearms/search`.

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/head-to-head?a=glock-17-gen5&b=sig-sauer-p320-full-size' \
  --header 'X-API-Key: your_key'
```

## Find by Archetype

`GET /v1/firearms/game-meta`

Auth: API key required  
Tier: Builder+

Returns every firearm carrying game stats, classified into archetypes. Filter by archetype to get just that role roster.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `archetype` | string | no | Restrict to a single archetype |
| `If-None-Match` | string | no | The `ETag` from a previous response. If the body would be byte-identical, the API answers `304 Not Modified` with no body. A 304 counts toward the per-minute rate limit but not the daily request allowance. |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | GameProfile[] | The response payload. |
| `data[].id` | string | Slug of the firearm this profile is for. |
| `data[].name` | string | Display name, as the maker writes it. |
| `data[].gameDamage` | integer \| null | Editorial game statistic, 0-100. **Not a measured figure.** These are balance numbers for game use and are not derived from the ballistics above. |
| `data[].gameAccuracy` | integer \| null | Editorial game statistic, 0-100. Not a measured figure. |
| `data[].gameRange` | integer \| null | Editorial game statistic, 0-100. Not a measured figure. |
| `data[].gameFireRate` | integer \| null | Editorial game statistic, 0-100. Not a measured figure. |
| `data[].gameMobility` | integer \| null | Editorial game statistic, 0-100. Not a measured figure. |
| `data[].gameRecoilControl` | integer \| null | Editorial game statistic, 0-100. Not a measured figure. |
| `data[].gameReloadSpeed` | integer \| null | Editorial game statistic, 0-100. Not a measured figure. |
| `data[].gameConcealment` | integer \| null | Editorial game statistic, 0-100. Not a measured figure. |
| `data[].archetype` | GameArchetype | The role a firearm's game profile assigns it. |
| `data[].strengths` | string[] | The statistics this firearm scores highest on. |
| `data[].weaknesses` | string[] | The statistics it scores lowest on. |

```json
{
  "success": true,
  "data": [
    {
      "id": "glock-17-gen5",
      "name": "Glock 17 Gen5",
      "gameDamage": 45,
      "gameAccuracy": 70,
      "gameRange": 35,
      "gameFireRate": 55,
      "gameMobility": 85,
      "gameRecoilControl": 65,
      "gameReloadSpeed": 75,
      "gameConcealment": 80,
      "archetype": "sniper",
      "strengths": [
        "mobility",
        "concealment"
      ],
      "weaknesses": [
        "range"
      ]
    }
  ]
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- Requires **Builder** tier or higher.
- Valid archetypes: `sniper`, `assault`, `tank`, `glass-cannon`, `all-rounder`, `support`, `stealth`, `speedster`.
- Archetype classification uses weighted scoring across all 8 game stats.
- Omit the archetype parameter to retrieve all classified firearms.

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/game-meta?archetype=sniper' \
  --header 'X-API-Key: your_key'
```

## List Action Types

`GET /v1/firearms/action-types`

Auth: API key required  
Tier: Explorer+

Returns every distinct action type in the catalog with how many firearms use it.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `If-None-Match` | string | no | The `ETag` from a previous response. If the body would be byte-identical, the API answers `304 Not Modified` with no body. A 304 counts toward the per-minute rate limit but not the daily request allowance. |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object[] | The response payload. |
| `data[].id` | string | The action type as stored, and the value to send as `action_type` when filtering. |
| `data[].name` | string | Display name, as the maker writes it. |

```json
{
  "success": true,
  "data": [
    {
      "id": "blow_forward",
      "name": "Blow Forward"
    }
  ]
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- Pass `data[].id` to the `action_type` filter on `GET /v1/firearms`.

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/action-types' \
  --header 'X-API-Key: your_key'
```

## Filter Options

`GET /v1/firearms/filter-options`

Auth: API key required  
Tier: Explorer+

Returns the distinct values available for each filterable field, for building filter UIs without hardcoding the options. Categories, manufacturers and calibers no firearm uses are omitted.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `If-None-Match` | string | no | The `ETag` from a previous response. If the body would be byte-identical, the API answers `304 Not Modified` with no body. A 304 counts toward the per-minute rate limit but not the daily request allowance. |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object | The distinct filter values currently present in the catalog. |
| `data.manufacturers` | object[] | Manufacturers with at least one firearm. |
| `data.manufacturers[].id` | string | Slug of the manufacturer, and the value to send as `manufacturer`. |
| `data.manufacturers[].name` | string | Display name, as the maker writes it. |
| `data.manufacturers[].countryCode` | string \| null | ISO 3166-1 alpha-2 code of where they are based, for grouping the list by country. |
| `data.categories` | object[] | Categories with at least one firearm. |
| `data.categories[].slug` | string | Slug of the category, and the value to send as `category`. Keyed `slug` here rather than `id`, unlike the other lists. |
| `data.categories[].name` | string | Display name, as the maker writes it. |
| `data.calibers` | object[] | Cartridges chambered by at least one firearm. |
| `data.calibers[].id` | string | Slug of the cartridge, and the value to send as `caliber`. |
| `data.calibers[].name` | string | Display name, as the maker writes it. |
| `data.actionTypes` | object[] | Operating actions present in the catalog. |
| `data.actionTypes[].id` | string | The action type as stored, and the value to send as `action_type`. |
| `data.actionTypes[].name` | string | Display name, as the maker writes it. |
| `data.features` | object[] | JSON array of notable features, as a string. The same vocabulary `/v1/firearms/by-feature` filters on. |
| `data.features[].id` | string | The feature as stored, and the value to send as `feature` to /v1/firearms/by-feature. |
| `data.features[].name` | string | Display name, as the maker writes it. |

```json
{
  "success": true,
  "data": {
    "manufacturers": [
      {
        "id": "glock",
        "name": "Glock",
        "countryCode": "AT"
      }
    ],
    "categories": [
      {
        "slug": "pistol",
        "name": "Pistol"
      }
    ],
    "calibers": [
      {
        "id": "9x19mm-parabellum",
        "name": "9x19mm Parabellum"
      }
    ],
    "actionTypes": [
      {
        "id": "semi_automatic",
        "name": "Semi Automatic"
      }
    ],
    "features": [
      {
        "id": "threaded barrel",
        "name": "Threaded Barrel"
      }
    ]
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- One round-trip for an entire filter UI. Calibers are capped at the 100 most relevant.
- Use `categories[].slug`, `manufacturers[].id`, `calibers[].id`, `actionTypes[].id`, and `features[].id` as filter values on `GET /v1/firearms`.

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/filter-options' \
  --header 'X-API-Key: your_key'
```

## By Feature

`GET /v1/firearms/by-feature`

Auth: API key required  
Tier: Builder+

Returns firearms carrying a given feature slug, e.g. `threaded-barrel` or `m-lok`.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `feature` | string | yes | Feature name as stored, e.g. `threaded barrel`. The vocabulary is `features` on /v1/firearms/filter-options |
| `category` | string | no | Restrict to a category slug |
| `page` | integer | no | Page number, from 1 to 10,000 |
| `per_page` | integer | no | Items per page (max 100) |
| `If-None-Match` | string | no | The `ETag` from a previous response. If the body would be byte-identical, the API answers `304 Not Modified` with no body. A 304 counts toward the per-minute rate limit but not the daily request allowance. |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object[] | One page of results |
| `data[].id` | string | Slug of the firearm. |
| `data[].name` | string | Display name, as the maker writes it. |
| `data[].manufacturerId` | string \| null | Slug of the manufacturer that makes it. Resolve against `/v1/manufacturers/{id}`. |
| `data[].categoryId` | string \| null | Slug of the category it belongs to. Resolve against `/v1/categories`. |
| `data[].features` | string[] | Every feature this firearm carries, not only the one filtered on. |
| `pagination.page` | number | Current page number |
| `pagination.per_page` | number | Items per page |
| `pagination.total` | number | Total matching records (Builder and above) |

```json
{
  "success": true,
  "data": [
    {
      "id": "m16a1",
      "name": "M16A1",
      "manufacturerId": "colt",
      "categoryId": "rifle",
      "features": [
        "threaded_barrel",
        "accessory_rail"
      ]
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "per_page": 20,
    "total": 9162,
    "totalPages": 459
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- Requires **Builder** tier or higher.
- Common features: `suppressor_ready`, `ambidextrous`, `picatinny_rail`, `threaded_barrel`, `adjustable_stock`, `folding_stock`, `free_float_barrel`, `match_trigger`.

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/by-feature?feature=suppressor_ready&category=pistol' \
  --header 'X-API-Key: your_key'
```

## By Action Type

`GET /v1/firearms/by-action`

Auth: API key required  
Tier: Explorer+

Returns firearms using a given action type.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `action` | string | yes | Action type as stored, underscored, e.g. `short_recoil`. The vocabulary is /v1/firearms/action-types |
| `page` | integer | no | Page number, from 1 to 10,000 |
| `per_page` | integer | no | Items per page (max 100) |
| `If-None-Match` | string | no | The `ETag` from a previous response. If the body would be byte-identical, the API answers `304 Not Modified` with no body. A 304 counts toward the per-minute rate limit but not the daily request allowance. |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object[] | One page of results |
| `data[].id` | string | Slug of the firearm. |
| `data[].name` | string | Display name, as the maker writes it. |
| `data[].manufacturerId` | string \| null | Slug of the manufacturer that makes it. Resolve against `/v1/manufacturers/{id}`. |
| `data[].categoryId` | string \| null | Slug of the category it belongs to. Resolve against `/v1/categories`. |
| `data[].yearIntroduced` | integer \| null | Year it entered production or service. Null where the date is disputed or unknown. |
| `data[].status` | string \| null | Production status, one of `in_production`, `discontinued`, `out_of_production`, `in_service`, `limited_production`, `prototype`. Discontinued records are kept deliberately: a rifle built in 2004 wears parts nobody sells today. |
| `data[].actionType` | string \| null | The action this firearm uses, as stored. |
| `pagination.page` | number | Current page number |
| `pagination.per_page` | number | Items per page |
| `pagination.total` | number | Total matching records (Builder and above) |

```json
{
  "success": true,
  "data": [
    {
      "id": "m16a1",
      "name": "M16A1",
      "manufacturerId": "colt",
      "categoryId": "rifle",
      "yearIntroduced": 1967,
      "status": "in_production",
      "actionType": "short_recoil"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "per_page": 20,
    "total": 9162,
    "totalPages": 459
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- Common action types: `bolt_action`, `gas_operated`, `gas_operated_long_stroke`, `gas_operated_short_stroke`, `striker_fired`, `blowback`, `recoil_operated`, `pump_action`, `lever_action`, `roller_delayed`.

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/by-action?action=bolt_action' \
  --header 'X-API-Key: your_key'
```

## By Material

`GET /v1/firearms/by-material`

Auth: API key required  
Tier: Builder+

Returns firearms whose named component is made of a given material.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `material` | string | yes | Material name |
| `component` | string | yes | Which component the material applies to |
| `page` | integer | no | Page number, from 1 to 10,000 |
| `per_page` | integer | no | Items per page (max 100) |
| `If-None-Match` | string | no | The `ETag` from a previous response. If the body would be byte-identical, the API answers `304 Not Modified` with no body. A 304 counts toward the per-minute rate limit but not the daily request allowance. |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object[] | One page of results |
| `data[].id` | string | Slug of the firearm. |
| `data[].name` | string | Display name, as the maker writes it. |
| `data[].manufacturerId` | string \| null | Slug of the manufacturer that makes it. Resolve against `/v1/manufacturers/{id}`. |
| `data[].categoryId` | string \| null | Slug of the category it belongs to. Resolve against `/v1/categories`. |
| `data[].material` | string \| null | The material recorded for the component that was filtered on. Matched case-insensitively, returned as stored. |
| `pagination.page` | number | Current page number |
| `pagination.per_page` | number | Items per page |
| `pagination.total` | number | Total matching records (Builder and above) |

```json
{
  "success": true,
  "data": [
    {
      "id": "m16a1",
      "name": "M16A1",
      "manufacturerId": "colt",
      "categoryId": "rifle",
      "material": "Steel"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "per_page": 20,
    "total": 9162,
    "totalPages": 459
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- Requires **Builder** tier or higher.
- Valid components: `frame`, `barrel`, `stock`, `slide`.
- Material names are case-insensitive. Common values: `polymer`, `steel`, `stainless_steel`, `aluminum_alloy`, `titanium`, `wood`, `carbon_fiber`.

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/by-material?material=polymer&component=frame' \
  --header 'X-API-Key: your_key'
```

## By Designer

`GET /v1/firearms/by-designer`

Auth: API key required  
Tier: Builder+

Returns firearms credited to a given designer.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `designer` | string | yes | Designer name. Matched as a substring, so a surname alone works |
| `page` | integer | no | Page number, from 1 to 10,000 |
| `per_page` | integer | no | Items per page (max 100) |
| `If-None-Match` | string | no | The `ETag` from a previous response. If the body would be byte-identical, the API answers `304 Not Modified` with no body. A 304 counts toward the per-minute rate limit but not the daily request allowance. |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object[] | One page of results |
| `data[].id` | string | Slug of the firearm. |
| `data[].name` | string | Display name, as the maker writes it. |
| `data[].manufacturerId` | string \| null | Slug of the manufacturer that makes it. Resolve against `/v1/manufacturers/{id}`. |
| `data[].categoryId` | string \| null | Slug of the category it belongs to. Resolve against `/v1/categories`. |
| `data[].designer` | string \| null | Everyone credited on this firearm, as stored, not only the name filtered on. |
| `pagination.page` | number | Current page number |
| `pagination.per_page` | number | Items per page |
| `pagination.total` | number | Total matching records (Builder and above) |

```json
{
  "success": true,
  "data": [
    {
      "id": "m16a1",
      "name": "M16A1",
      "manufacturerId": "colt",
      "categoryId": "rifle",
      "designer": "John Browning"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "per_page": 20,
    "total": 9162,
    "totalPages": 459
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- Requires **Builder** tier or higher.
- Designer slug uses kebab-case (e.g. `john-browning`, `mikhail-kalashnikov`, `eugene-stoner`).
- Only firearms with a known designer field are included.

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/by-designer?designer=john-browning' \
  --header 'X-API-Key: your_key'
```

## By Conflict

`GET /v1/firearms/by-conflict`

Auth: API key required  
Tier: Studio+

Returns firearms recorded as used in a given armed conflict.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `conflict` | string | yes | Conflict name |
| `page` | integer | no | Page number, from 1 to 10,000 |
| `per_page` | integer | no | Items per page (max 100) |
| `If-None-Match` | string | no | The `ETag` from a previous response. If the body would be byte-identical, the API answers `304 Not Modified` with no body. A 304 counts toward the per-minute rate limit but not the daily request allowance. |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object[] | One page of results |
| `data[].id` | string | Slug of the firearm. |
| `data[].name` | string | Display name, as the maker writes it. |
| `data[].manufacturerId` | string \| null | Slug of the manufacturer that makes it. Resolve against `/v1/manufacturers/{id}`. |
| `data[].categoryId` | string \| null | Slug of the category it belongs to. Resolve against `/v1/categories`. |
| `data[].yearIntroduced` | integer \| null | Year it entered production or service. Null where the date is disputed or unknown. |
| `data[].conflicts` | object[] | Every conflict this firearm is recorded in, not only the one filtered on. |
| `data[].conflicts[].name` | string | The conflict. |
| `data[].conflicts[].years` | string | When it ran, as written. |
| `data[].conflicts[].sides` | string[] | The belligerents. |
| `pagination.page` | number | Current page number |
| `pagination.per_page` | number | Items per page |
| `pagination.total` | number | Total matching records (Builder and above) |

```json
{
  "success": true,
  "data": [
    {
      "id": "m16a1",
      "name": "M16A1",
      "manufacturerId": "colt",
      "categoryId": "rifle",
      "yearIntroduced": 1967,
      "conflicts": [
        {
          "name": "Vietnam War",
          "years": "1955-1975",
          "sides": [
            "North Vietnam / Viet Cong",
            "South Vietnam / United States"
          ]
        }
      ]
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "per_page": 20,
    "total": 9162,
    "totalPages": 459
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- Requires **Studio** tier or higher.
- Conflict data is extracted from the `conflicts` JSON field on each firearm record.
- Use the `/v1/conflicts` endpoint to discover available conflict names.

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/by-conflict?conflict=vietnam-war' \
  --header 'X-API-Key: your_key'
```

## Power Rating

`GET /v1/firearms/power-rating`

Auth: API key required  
Tier: Builder+

Returns firearms scored on a 0-100 composite of muzzle energy (30%), effective range (25%), rate of fire (20%), magazine capacity (15%), and mobility (10%). Firearms without a recorded muzzle energy are excluded.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `category` | string | no | Restrict the ranking to a category slug |
| `page` | integer | no | Page number, from 1 to 10,000 |
| `per_page` | integer | no | Items per page (max 100) |
| `If-None-Match` | string | no | The `ETag` from a previous response. If the body would be byte-identical, the API answers `304 Not Modified` with no body. A 304 counts toward the per-minute rate limit but not the daily request allowance. |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object[] | One page of results |
| `data[].id` | string | Slug of the firearm. |
| `data[].name` | string | Display name, as the maker writes it. |
| `data[].manufacturerId` | string | Slug of the manufacturer that makes it. Resolve against `/v1/manufacturers/{id}`. |
| `data[].categoryId` | string | Slug of the category it belongs to. Resolve against `/v1/categories`. |
| `data[].powerRating` | number | Composite score from 0 to 100, the sum of the weighted components in `breakdown`. |
| `data[].breakdown` | object | The weighted components `powerRating` is the sum of. |
| `data[].breakdown.energy` | number | Muzzle-energy component, out of 30. |
| `data[].breakdown.range` | number | Effective-range component, out of 25. |
| `data[].breakdown.fireRate` | number | Rate-of-fire component, out of 20. |
| `data[].breakdown.capacity` | number | Magazine-capacity component, out of 15. |
| `data[].breakdown.mobility` | number | Mobility component, out of 10. Lighter and shorter scores higher. |
| `pagination.page` | number | Current page number |
| `pagination.per_page` | number | Items per page |
| `pagination.total` | number | Total matching records (Builder and above) |

```json
{
  "success": true,
  "data": [
    {
      "id": "browning-m3m",
      "name": "Browning M3M",
      "manufacturerId": "fn-herstal",
      "categoryId": "machine-gun",
      "powerRating": 86.46,
      "breakdown": {
        "energy": 30,
        "range": 23.13,
        "fireRate": 18.33,
        "capacity": 15,
        "mobility": 0
      }
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "per_page": 20,
    "total": 9162,
    "totalPages": 459
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- Requires **Builder** tier or higher.
- Power rating is a weighted composite: muzzle energy (35%), effective range (25%), rate of fire (20%), magazine capacity (20%).
- Firearms missing required spec fields are excluded from rankings.
- Results are sorted by power_rating descending.

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/power-rating?category=rifle' \
  --header 'X-API-Key: your_key'
```

## Timeline

`GET /v1/firearms/timeline`

Auth: API key required  
Tier: Builder+

Returns firearms ordered by year of introduction, optionally bounded to a period. Firearms without a recorded introduction year are excluded.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `from` | integer | no | Earliest year of introduction |
| `to` | integer | no | Latest year of introduction |
| `category` | string | no | Restrict to a category slug |
| `page` | integer | no | Page number |
| `per_page` | integer | no | Items per page (max 100) |
| `order` | string | no | Sort order |
| `If-None-Match` | string | no | The `ETag` from a previous response. If the body would be byte-identical, the API answers `304 Not Modified` with no body. A 304 counts toward the per-minute rate limit but not the daily request allowance. |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | TimelineEntry[] | One page of results |
| `data[].id` | string | URL-safe slug identifying the firearm, e.g. `glock-17-gen5`. |
| `data[].name` | string | Display name, as the maker writes it. |
| `data[].manufacturerId` | string \| null | Slug of the manufacturer that makes it. Resolve against `/v1/manufacturers/{id}`. |
| `data[].categoryId` | string \| null | Slug of the category it belongs to. Resolve against `/v1/categories`. |
| `data[].yearIntroduced` | integer \| null | Year it entered production or service. Null where the date is disputed or unknown. |
| `data[].yearDiscontinued` | integer \| null | Year production ended, or null while it is still made. |
| `data[].status` | string \| null | Production status, one of `in_production`, `discontinued`, `out_of_production`, `in_service`, `limited_production`, `prototype`. Discontinued records are kept deliberately: a rifle built in 2004 wears parts nobody sells today. |
| `data[].countryOfOrigin` | string \| null | ISO 3166-1 alpha-2 code of where it was designed, e.g. `AT`. |
| `pagination.page` | number | Current page number |
| `pagination.per_page` | number | Items per page |
| `pagination.total` | number | Total matching records (Builder and above) |

```json
{
  "success": true,
  "data": [
    {
      "id": "ak-47",
      "name": "AK-47",
      "manufacturerId": "izhmash",
      "categoryId": "rifle",
      "yearIntroduced": 1949,
      "yearDiscontinued": 1959,
      "status": "discontinued",
      "countryOfOrigin": "RU"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "per_page": 20,
    "total": 9162,
    "totalPages": 459
  }
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- Requires **Builder** tier or higher.
- Results are sorted by year_introduced ascending.
- Firearms without a known year_introduced are excluded.
- Use `from` and `to` to narrow the time window.

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/firearms/timeline?from=1900&to=1950&category=rifle' \
  --header 'X-API-Key: your_key'
```

## Popular Firearms

`GET /v1/popular/firearms`

Auth: API key required  
Tier: Explorer+

Returns firearms ranked by recorded view activity over a trailing window.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `days` | integer | no | Trailing window in days |
| `limit` | integer | no | How many to return |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | PopularFirearm[] | The response payload. |
| `data[].entity_id` | string | Slug of the firearm. Named `entity_id` because the rollup counts views of any entity kind; it is `id` on the catalog endpoints. |
| `data[].entity_name` | string | Display name of the firearm. |
| `data[].manufacturer_id` | string \| null | Slug of the manufacturer. `manufacturerId` on the camelCase endpoints. |
| `data[].category_id` | string \| null | Slug of the category. `categoryId` on the camelCase endpoints. |
| `data[].country_of_origin` | string \| null | ISO 3166-1 alpha-2 code of the country of origin. |
| `data[].year_introduced` | integer \| null | Year the model was introduced. |
| `data[].status` | FirearmStatus \| null | Production status, one of `in_production`, `discontinued`, `out_of_production`, `in_service`, `limited_production`, `prototype`. Discontinued records are kept deliberately: a rifle built in 2004 wears parts nobody sells today. |
| `data[].svg_line_art_url` | string \| null | Line-art silhouette, or null where none has been drawn. |
| `data[].total_views` | integer | Views recorded inside the trailing window given by `days`. |
| `data[].total_unique_users` | integer | Distinct visitors behind those views. Forty views from one reader is a different fact from forty readers. |

```json
{
  "success": true,
  "data": [
    {
      "entity_id": "glock-17-gen5",
      "entity_name": "Glock 17 Gen5",
      "manufacturer_id": "glock",
      "category_id": "pistol",
      "country_of_origin": "AT",
      "year_introduced": 2017,
      "status": "in_production",
      "svg_line_art_url": "https://api.gunspec.io/v1/firearms/glock-17-gen5/media/silhouette",
      "total_views": 49,
      "total_unique_users": 4
    }
  ]
}
```

### Errors

| Status | Code | Message |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | Validation error |
| 401 | `UNAUTHORIZED` | API key required |
| 403 | `FORBIDDEN` | Valid key, not permitted |
| 429 | `RATE_LIMITED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Unexpected server error |

### Notes

- Ranking is based on anonymised view analytics, refreshed daily.
- The window is capped at 30 days and the result set at 20 firearms.

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/popular/firearms?days=7&limit=10' \
  --header 'X-API-Key: your_key'
```
