# Compatibility

Fit is computed from **mount interfaces**, never from names. A firearm declares what it exposes at each position (muzzle thread, rails, optic cut, stock and grip interfaces, magazine well); an attachment declares what it requires; the engine matches the two, through a named adapter where one bridges them. A caliber, bore or minimum-barrel mismatch is never bridged and never inferred.

**Every fit carries `source` and `confidence`, and they are the first two fields to read.** `source` is the weakest evidence in the chain that produced the fit; `confidence` is capped by the weakest interface it went through. The values, strongest first:

- `curated`: a person recorded this interface or this fit against the maker's page.
- `universal`: the attachment requires nothing (a sling), so every firearm fits it.
- `inherited:parent`: the interface came from the firearm's parent record.
- `inherited:platform`: the interface came from a platform the firearm belongs to (an AK-100 default, a mil-spec AR-15 default).
- `inferred`: the interface was derived by a script from the record's own fields, at 0.6 or below. **Convention rows are the weakest inferred evidence and are labelled as such**: where a firearm is tagged threaded or optics-ready but names no standard, the backfill applies industry convention (a thread by cartridge and country, an optic plate by the maker's system) at 0.5 to 0.6, with a `convention:` note on the interface row.

**A retailer or an engineering consumer should treat `inferred`, and anything below a confidence they choose, as unverified.** Pass `min_confidence` to hide fits computed through interfaces below that figure; nothing in a response presents an inferred or convention row as verified, and neither should a product page built on it. `via[]` names each interface the fit went through with its own `source`, so a fit can be audited hop by hop; `GET /v1/firearms/{id}/interfaces` lists every row with its `source`, `confidence` and `inheritedFrom`.

Browsing the catalog (`/v1/attachments`, `/v1/attachments/{id}`, `/v1/interfaces`) is open to any key. Everything that answers "does this go on that" is Studio, and a lower plan receives `403 SUBSCRIPTION_REQUIRED` rather than an empty list, so never read an error as "nothing fits". `fits=` on the attachment list is the same computation wearing a query parameter and is gated the same way.

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

```mermaid
flowchart TD
  fit["A fit on the list"] --> src{"source"}
  src -->|"curated or universal"| stated["Somebody stated this fit"]:::ok
  src -->|"inherited"| family["Taken from the family<br/>via[] names each step"]:::ok
  src -->|"inferred"| derived["Derived, not verified"]:::warn
  stated --> conf{"confidence ≥ 0.7?"}
  family --> conf
  derived --> conf
  conf -->|"yes"| show["Safe to show as a fit"]:::ok
  conf -->|"no"| hide["Hide it, or label it unverified"]:::warn
```

## Get a firearm's mount interfaces

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

Auth: API key required  
Tier: Studio+

The mount points a firearm exposes (muzzle thread, rails, optic cut, stock and grip interfaces, magazine well), resolved from its own rows, its parent chain and its platforms. Each row carries where it came from and a confidence, so an inferred claim is never mistaken for a verified one. This is what a builder UI draws its slot panel from.

### Path parameters

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

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object | The response payload. |
| `data.firearm` | object | A firearm, identified by slug and name. |
| `data.firearm.id` | string | Slug of the firearm. |
| `data.firearm.name` | string | Display name, as the maker writes it. |
| `data.platforms` | object[] | Platforms this firearm belongs to, whose interfaces it inherits. Reached through its own memberships and its parent chain. |
| `data.platforms[].id` | string | Slug of the platform. |
| `data.platforms[].name` | string | Display name, as the maker writes it. |
| `data.interfaces` | ResolvedInterface[] | The mount interfaces resolved for this firearm, own rows first, then inherited. |
| `data.interfaces[].position` | string | Where on the firearm the interface sits: `muzzle`, `rear`, `top`, `bottom`. |
| `data.interfaces[].standardId` | string | Id of the interface standard, kind-prefixed, e.g. `thread:1/2x28`. Ids are never renamed; new spellings become aliases. |
| `data.interfaces[].name` | string | Display name, as the maker writes it. |
| `data.interfaces[].kind` | string | What sort of interface it is: `thread`, `rail`, `mag`, `stock_mount`, `optic_plate`. Not a media kind: the shared registry filled this with `silhouette`/`render` until the consistency check caught it. |
| `data.interfaces[].source` | InterfaceSource | Where an interface row on a firearm came from. `curated` is a person; `inferred` is the backfill, capped at 0.6 confidence; the inherited kinds came through the parent chain or a platform. |
| `data.interfaces[].confidence` | number | How well evidenced this row is, 0 to 1. **A minimum, not an average**: an inferred row caps every fit computed through it, and `min_confidence` on the endpoint hides rows below a threshold. |
| `data.interfaces[].inheritedFrom` | string \| null | The parent firearm or platform this interface came from, or null when the firearm declares it itself. |
| `data.interfaces[].notes` | string \| null | Free-text notes that do not belong in a specific field. |

```json
{
  "success": true,
  "data": {
    "firearm": {
      "id": "ak-74m",
      "name": "AK-74M"
    },
    "platforms": [
      {
        "id": "ak-100",
        "name": "AK-100 series (AK-74M pattern)"
      }
    ],
    "interfaces": [
      {
        "position": "muzzle",
        "standardId": "thread:m24x1.5-rh",
        "name": "M24x1.5 right hand (AK-74, AK-100 series)",
        "kind": "thread",
        "source": "curated",
        "confidence": 1,
        "inheritedFrom": null,
        "notes": 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

- Every row carries `source` and `confidence`. `inherited:parent` and `inherited:platform` rows name where they came from in `inheritedFrom`; `inferred` rows are at 0.6 or below and a `convention:` note marks the weakest of them.
- A `none:<kind>` standard at a position means the firearm declares it has nothing there, which also stops a platform default from applying.

### Example

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

## List attachments that fit a firearm

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

Auth: API key required  
Tier: Studio+

Every catalogued attachment that fits the firearm, grouped by category. A retailer embeds this on a product page with `vendor=me&only_offered=true&with_offers=true`, which answers "what fits this gun, that I stock, at what price" in one request. Full detail: each with how it fits (direct, through a named adapter, or curated), which interface satisfied it, and a confidence. `include=all` adds the candidates that do not fit with the reason they were blocked, which is how a builder shows a greyed-out part. Rated items (suppressors, magazines) pass a caliber, bore and minimum-barrel gate that no adapter can bridge.

### Path parameters

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

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `category` | string | no | Attachment category |
| `manufacturer` | string | no | Manufacturer slug |
| `include` | string | no | `compatible` (default) or `all` to include blocked candidates with `blockedBy` |
| `min_confidence` | number | no | Hide fits computed through interfaces below this confidence |
| `vendor` | string | no | Restrict to one seller. `me` resolves to the vendor your key belongs to |
| `only_offered` | boolean | no | Drop anything that seller does not currently stock. Needs a resolvable `vendor`, and yields nothing without one |
| `with_offers` | boolean | no | Attach each item's live offers under `offers`, keyed by attachment id, so price and stock render beside fitment in one call |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object | The response payload. |
| `data.firearm` | object | A firearm, identified by slug and name. |
| `data.firearm.id` | string | Slug of the firearm. |
| `data.firearm.name` | string | Display name, as the maker writes it. |
| `data.platforms` | object[] | Platforms this firearm belongs to, whose interfaces it inherits. |
| `data.platforms[].id` | string | Slug of the platform. |
| `data.platforms[].name` | string | Display name, as the maker writes it. |
| `data.interfaces` | ResolvedInterface[] | The mount interfaces resolved for this firearm, own rows first, then inherited. |
| `data.interfaces[].position` | string | Where on the firearm the interface sits: `muzzle`, `rear`, `top`, `bottom`. |
| `data.interfaces[].standardId` | string | Id of the interface standard, kind-prefixed, e.g. `thread:1/2x28`. Ids are never renamed; new spellings become aliases. |
| `data.interfaces[].name` | string | Display name, as the maker writes it. |
| `data.interfaces[].kind` | string | What sort of interface it is: `thread`, `rail`, `mag`, `stock_mount`, `optic_plate`. Not a media kind: the shared registry filled this with `silhouette`/`render` until the consistency check caught it. |
| `data.interfaces[].source` | InterfaceSource | Where an interface row on a firearm came from. `curated` is a person; `inferred` is the backfill, capped at 0.6 confidence; the inherited kinds came through the parent chain or a platform. |
| `data.interfaces[].confidence` | number | How well evidenced this row is, 0 to 1. **A minimum, not an average**: an inferred row caps every fit computed through it, and `min_confidence` on the endpoint hides rows below a threshold. |
| `data.interfaces[].inheritedFrom` | string \| null | The parent firearm or platform this interface came from, or null when the firearm declares it itself. |
| `data.interfaces[].notes` | string \| null | Free-text notes that do not belong in a specific field. |
| `data.groups` | object[] | Fitting attachments grouped by category, so a builder UI renders one panel per slot. |
| `data.groups[].category` | string | Attachment category this group holds. |
| `data.groups[].items` | AttachmentFit[] | The rows this response carries. |
| `data.counts` | object | Fitting attachments per category |
| `data.offers` | object \| null | With `with_offers=true`, live offers keyed by attachment id, the same rows `/v1/attachments/{id}/offers` returns; null when not requested. |
| `data.total` | integer | Total records matching the query across every page. |

```json
{
  "success": true,
  "data": {
    "firearm": {
      "id": "ak-74m",
      "name": "AK-74M"
    },
    "platforms": [
      {
        "id": "ak-100",
        "name": "AK-100 series (AK-74M pattern)"
      }
    ],
    "interfaces": [
      {
        "position": "muzzle",
        "standardId": "thread:m24x1.5-rh",
        "name": "M24x1.5 right hand (AK-74, AK-100 series)",
        "kind": "thread",
        "source": "curated",
        "confidence": 1,
        "inheritedFrom": null,
        "notes": null
      }
    ],
    "groups": [
      {
        "category": "stock",
        "items": [
          {
            "id": "magpul-zhukov-s-stock",
            "name": "Zhukov-S Stock",
            "category": "stock",
            "manufacturer": {
              "id": "magpul",
              "name": "Magpul Industries"
            },
            "modelNumber": "MAG585",
            "weightG": 490,
            "lengthMm": 240,
            "capacity": null,
            "boreDiameterMm": null,
            "requiresPosition": null,
            "isFactoryPart": false,
            "factoryFor": null,
            "statMods": {
              "mobility": 3,
              "recoil_control": 2
            },
            "specDeltas": {
              "folded_length_mm": 705
            },
            "imageUrl": "https://assets.gunspec.io/content/attachments/optic/aimpoint-acro-p-2/01-product.webp",
            "status": "in_production",
            "dataConfidence": 0.9,
            "updatedAt": "2026-09-07 12:56:03",
            "version": "e27e61b4b373f5a621d6ae5c67d521096131f2cf56300a1b2c3d4e5f6a7b8c9d",
            "provenance": {
              "sources": [
                "https://www.glock.com/en/products/pistols/g17-gen5"
              ],
              "sourceKinds": [
                {
                  "url": "https://www.glock.com/en/products/pistols/g17-gen5",
                  "kind": "manufacturer",
                  "archivedFrom": null,
                  "rank": 0,
                  "tier": "authority",
                  "basis": "declared",
                  "evidence": null
                }
              ],
              "bestSourceKind": "manufacturer",
              "makerSource": "https://www.glock.com/en/products/pistols/g17-gen5",
              "evidence": "maker",
              "checks": {
                "raised": 3,
                "open": 0,
                "history": [
                  {
                    "task": "verify:firearms:glock-g17-gen5",
                    "number": 1204,
                    "check": "quality:firearm-weight-fits-its-size",
                    "kind": "verify",
                    "agent": "redback",
                    "worker": "redback-046535",
                    "status": "resolved",
                    "decidedAt": "2026-09-25 07:42:39",
                    "proposed": "Read https://us.glock.com/en/pistols/g17-gen5: agrees on barrel_length, weight.",
                    "fields": [
                      "barrel_length_mm",
                      "weight_empty_g"
                    ],
                    "sources": [
                      "https://us.glock.com/en/pistols/g17-gen5"
                    ],
                    "review": {
                      "decision": "accepted",
                      "at": null
                    },
                    "finding": {
                      "why": "2.22 g/cm3 against a submachine-gun / carbon-steel median of 0.64; check the weight and the dimensions",
                      "evidence": {
                        "apparentDensity": 2.218,
                        "categoryMedian": 0.635
                      }
                    }
                  }
                ]
              },
              "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"
            },
            "provides": [],
            "fits": true,
            "fitType": "direct",
            "source": "curated",
            "via": [
              {
                "position": "rear",
                "standardId": "stock:ak-100-folder",
                "source": "curated",
                "adapterId": null
              }
            ],
            "adapter": {
              "id": "rs-regulate-ak-303m",
              "name": "RS Regulate AK-303M"
            },
            "confidence": 1,
            "reason": "Mounts on AK-100 series polymer side-folder trunnion (rear)",
            "blockedBy": null,
            "routes": [
              {
                "fitType": "adapter",
                "adapter": {
                  "id": "rs-regulate-ak-303m",
                  "name": "RS Regulate AK-303M"
                },
                "via": [
                  {
                    "position": "rear",
                    "standardId": "stock:ak-100-folder",
                    "source": "curated",
                    "adapterId": null
                  }
                ],
                "confidence": 1,
                "source": "curated",
                "adapterConfidence": 0.95,
                "caveats": [
                  "Needs 30 mm rings, not listed here"
                ]
              }
            ],
            "caveats": []
          }
        ]
      }
    ],
    "counts": {
      "stock": 4,
      "optic": 12,
      "suppressor": 1
    },
    "offers": null,
    "total": 42
  }
}
```

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

- Read `source` and `confidence` on every item before anything else. `source` is the weakest evidence behind the fit (`curated`, `universal`, `inherited:parent`, `inherited:platform` or `inferred`) and `confidence` is capped by the weakest interface it went through; `via[]` names each interface with its own `source`. `inferred` means a script derived the interface the fit went through; a product page should say so or hide it.
- `min_confidence=0.7` is the usual retail setting: it drops every fit that went through a convention row.
- A retailer adds `vendor=me&only_offered=true&with_offers=true` to answer "what fits this gun, that I stock, at what price" in one call.

### Example

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

## List attachments

`GET /v1/attachments`

Auth: API key required  
Tier: Explorer+

The attachment catalog: stocks, grips, handguards, optics, mounts, muzzle devices, suppressors, magazines, lights, bipods, slings, triggers and adapters. `requires=<standard>` narrows to what needs a given interface. Browsing is open to any key. `fits=<firearm>` runs the compatibility engine and therefore needs Studio, the same as the dedicated fit endpoints.

### 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) |
| `category` | string | no | Attachment category |
| `manufacturer` | string | no | Manufacturer slug |
| `fits` | string | no | Only attachments that fit this firearm |
| `requires` | string | no | Only attachments requiring this interface standard |
| `q` | string | no | Substring match on name |
| `factory` | boolean | no | Only factory parts (`true`) or only aftermarket (`false`) |
| `status` | string | no | Production status. The catalog keeps discontinued parts on purpose, so a store front asks for `in_production` |
| `vendor` | string | no | Restrict to one seller. `me` resolves to the vendor your key belongs to |
| `only_offered` | boolean | no | Drop anything that seller does not currently stock. Needs a resolvable `vendor`, and yields nothing without one |
| `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` | Attachment[] | One page of results |
| `data[].id` | string | URL-safe slug identifying the part, e.g. `magpul-zhukov-s-stock`. |
| `data[].name` | string | Display name, as the maker writes it. |
| `data[].category` | string | What kind of part it is: `stock`, `optic`, `muzzle`, `magazine`, `grip`. |
| `data[].manufacturer` | ManufacturerRef \| null | Who makes it. |
| `data[].modelNumber` | string \| null | The maker's own SKU, where they publish one. |
| `data[].weightG` | number \| null | Weight in grams. |
| `data[].lengthMm` | number \| null | Length in millimetres. |
| `data[].capacity` | integer \| null | Magazines only |
| `data[].boreDiameterMm` | number \| null | Suppressors and muzzle devices |
| `data[].requiresPosition` | string \| null | The mount position this part occupies, or null when it takes none. |
| `data[].isFactoryPart` | boolean | True when the part ships on the firearm from the factory. A factory part carries no `statMods` or `specDeltas`, because the firearm's own figures already include it. |
| `data[].factoryFor` | string \| null | The firearm this is the factory part for, when `isFactoryPart` is true. |
| `data[].statMods` | object | Editorial adjustments to the game statistics once fitted, keyed by stat. Same status as `game_*` on a firearm: **not measured**. |
| `data[].specDeltas` | object | Specification changes once fitted, keyed by field: a folding stock changes the folded length, for example. |
| `data[].imageUrl` | string \| null | Absolute URL of the manufacturer product photo on the assets CDN, ready to render. Null when no photo has been sourced yet. |
| `data[].status` | AttachmentStatus | Production status of an attachment. `limited` corresponds to a firearm's `limited_production`. |
| `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[].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.makerSource` | string \| null | The first cited page on the record's own manufacturer's website, or null when it cites none. A page on another maker's site does not count. Cited from the maker is not the same as checked against it: `verifiedFields` names the figures that were read against a source. |
| `data[].provenance.evidence` | string | How far the record's figures are backed, strongest first, for a badge: `verified` when at least one figure was read against a source (`verifiedFields`), `maker` when it cites a page on its own manufacturer's site (`makerSource`), `authority` when it cites a maker, standards body or government page, `secondary` when it cites only press, retailers, references or forums, `none` when it cites nothing. Derived from the fields beside it. |
| `data[].provenance.checks` | object | The checks the record has been through, on a single record's response only: every task the data agent (Redback) or a person was given about it, what each proposed, the pages it read, and whether a person accepted it. Nothing an agent proposes reaches the record until a person accepts it, so this is the trail behind `verifiedFields`. Who reviewed is not published; an agent's instance name is. |
| `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[].provides` | string[] | Standard ids this adds to the firearm once fitted. Resolved to `{ id, name, kind }` rows on `/v1/attachments/{id}`. |
| `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": "magpul-zhukov-s-stock",
      "name": "Zhukov-S Stock",
      "category": "stock",
      "manufacturer": {
        "id": "magpul",
        "name": "Magpul Industries"
      },
      "modelNumber": "MAG585",
      "weightG": 490,
      "lengthMm": 240,
      "capacity": null,
      "boreDiameterMm": null,
      "requiresPosition": null,
      "isFactoryPart": false,
      "factoryFor": null,
      "statMods": {
        "mobility": 3,
        "recoil_control": 2
      },
      "specDeltas": {
        "folded_length_mm": 705
      },
      "imageUrl": "https://assets.gunspec.io/content/attachments/optic/aimpoint-acro-p-2/01-product.webp",
      "status": "in_production",
      "dataConfidence": 0.9,
      "updatedAt": "2026-09-07 12:56:03",
      "version": "e27e61b4b373f5a621d6ae5c67d521096131f2cf56300a1b2c3d4e5f6a7b8c9d",
      "provenance": {
        "sources": [
          "https://www.glock.com/en/products/pistols/g17-gen5"
        ],
        "sourceKinds": [
          {
            "url": "https://www.glock.com/en/products/pistols/g17-gen5",
            "kind": "manufacturer",
            "archivedFrom": null,
            "rank": 0,
            "tier": "authority",
            "basis": "declared",
            "evidence": null
          }
        ],
        "bestSourceKind": "manufacturer",
        "makerSource": "https://www.glock.com/en/products/pistols/g17-gen5",
        "evidence": "maker",
        "checks": {
          "raised": 3,
          "open": 0,
          "history": [
            {
              "task": "verify:firearms:glock-g17-gen5",
              "number": 1204,
              "check": "quality:firearm-weight-fits-its-size",
              "kind": "verify",
              "agent": "redback",
              "worker": "redback-046535",
              "status": "resolved",
              "decidedAt": "2026-09-25 07:42:39",
              "proposed": "Read https://us.glock.com/en/pistols/g17-gen5: agrees on barrel_length, weight.",
              "fields": [
                "barrel_length_mm",
                "weight_empty_g"
              ],
              "sources": [
                "https://us.glock.com/en/pistols/g17-gen5"
              ],
              "review": {
                "decision": "accepted",
                "at": null
              },
              "finding": {
                "why": "2.22 g/cm3 against a submachine-gun / carbon-steel median of 0.64; check the weight and the dimensions",
                "evidence": {
                  "apparentDensity": 2.218,
                  "categoryMedian": 0.635
                }
              }
            }
          ]
        },
        "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"
      },
      "provides": []
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "per_page": 20,
    "total": 8630,
    "totalPages": 432
  }
}
```

### 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/attachments' \
  --header 'X-API-Key: your_key'
```

## Get an attachment

`GET /v1/attachments/{id}`

Auth: API key required  
Tier: Explorer+

One attachment with its requirement groups (any of the standards within a group, every group across groups), what it provides once fitted, its caliber ratings and its stat and spec effects.

### Path parameters

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

### 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` | AttachmentDetail | The response payload |
| `data.id` | string | URL-safe slug identifying the part, e.g. `magpul-zhukov-s-stock`. |
| `data.name` | string | Display name, as the maker writes it. |
| `data.category` | string | What kind of part it is: `stock`, `optic`, `muzzle`, `magazine`, `grip`. |
| `data.manufacturer` | ManufacturerRef \| null | Who makes it. |
| `data.modelNumber` | string \| null | The maker's own SKU, where they publish one. |
| `data.weightG` | number \| null | Weight in grams. |
| `data.lengthMm` | number \| null | Length in millimetres. |
| `data.capacity` | integer \| null | Magazines only |
| `data.boreDiameterMm` | number \| null | Suppressors and muzzle devices |
| `data.requiresPosition` | string \| null | The mount position this part occupies, or null when it takes none. |
| `data.isFactoryPart` | boolean | True when the part ships on the firearm from the factory. A factory part carries no `statMods` or `specDeltas`, because the firearm's own figures already include it. |
| `data.factoryFor` | string \| null | The firearm this is the factory part for, when `isFactoryPart` is true. |
| `data.statMods` | object | Editorial adjustments to the game statistics once fitted, keyed by stat. Same status as `game_*` on a firearm: **not measured**. |
| `data.specDeltas` | object | Specification changes once fitted, keyed by field: a folding stock changes the folded length, for example. |
| `data.imageUrl` | string \| null | Absolute URL of the manufacturer product photo on the assets CDN, ready to render. Null when no photo has been sourced yet. |
| `data.status` | AttachmentStatus | Production status of an attachment. `limited` corresponds to a firearm's `limited_production`. |
| `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.updatedAt` | string | When a field we serve last changed. |
| `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.makerSource` | string \| null | The first cited page on the record's own manufacturer's website, or null when it cites none. A page on another maker's site does not count. Cited from the maker is not the same as checked against it: `verifiedFields` names the figures that were read against a source. |
| `data.provenance.evidence` | string | How far the record's figures are backed, strongest first, for a badge: `verified` when at least one figure was read against a source (`verifiedFields`), `maker` when it cites a page on its own manufacturer's site (`makerSource`), `authority` when it cites a maker, standards body or government page, `secondary` when it cites only press, retailers, references or forums, `none` when it cites nothing. Derived from the fields beside it. |
| `data.provenance.checks` | object | The checks the record has been through, on a single record's response only: every task the data agent (Redback) or a person was given about it, what each proposed, the pages it read, and whether a person accepted it. Nothing an agent proposes reaches the record until a person accepts it, so this is the trail behind `verifiedFields`. Who reviewed is not published; an agent's instance name is. |
| `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.description` | string \| null | Prose summary of the record, where one has been written. |
| `data.specs` | object | Category-specific facts |
| `data.sources` | string[] | JSON array of the pages the figures were taken from, as a string. |
| `data.sourceUrl` | string \| null | Page the asset or document was taken from. |
| `data.yearIntroduced` | integer \| null | Year it entered production or service. Null where the date is disputed or unknown. |
| `data.requires` | StandardRef[][] | Requirement groups. One standard from each group must be present on the firearm. Empty means universal fit. |
| `data.provides` | StandardRef[] | Standards this adds to the firearm once fitted, resolved to `{ id, name, kind, position }`. |
| `data.provides[].id` | string | Kind-prefixed standard id. |
| `data.provides[].name` | string | Display name, as the maker writes it. |
| `data.provides[].kind` | string | What sort of interface it is. |
| `data.provides[].position` | string \| null | Where on the firearm the standard sits once fitted, when that is not where this part mounts: a side mount clamps to the side rail and puts its Picatinny rail over the receiver. Null means where this part mounts. |
| `data.caliberRatings` | object[] | What this part is rated for, where being rated matters, meaning suppressors and magazines. The caliber, bore and minimum-barrel gate is checked against these and is never bridged or inferred. |
| `data.caliberRatings[].caliberId` | string | Slug of the cartridge. Resolve against `/v1/calibers/{id}`. |
| `data.caliberRatings[].caliberName` | string \| null | Display name of the cartridge. |
| `data.caliberRatings[].minBarrelLengthMm` | number \| null | Shortest barrel in millimetres this part is rated for in that cartridge. Null where the maker states none. |
| `data.caliberRatings[].fullAutoRated` | boolean | Whether the maker rates it for automatic fire in that cartridge. |
| `data.caliberRatings[].notes` | string \| null | Free-text notes that do not belong in a specific field. |
| `data.curatedFits` | integer | Number of firearms with a curated override for this attachment |
| `data.createdAt` | string | When the record was first added, `YYYY-MM-DD HH:MM:SS` in UTC. |

```json
{
  "success": true,
  "data": {
    "id": "magpul-zhukov-s-stock",
    "name": "Zhukov-S Stock",
    "category": "stock",
    "manufacturer": {
      "id": "magpul",
      "name": "Magpul Industries"
    },
    "modelNumber": "MAG585",
    "weightG": 490,
    "lengthMm": 240,
    "capacity": null,
    "boreDiameterMm": null,
    "requiresPosition": null,
    "isFactoryPart": false,
    "factoryFor": null,
    "statMods": {
      "mobility": 3,
      "recoil_control": 2
    },
    "specDeltas": {
      "folded_length_mm": 705
    },
    "imageUrl": "https://assets.gunspec.io/content/attachments/optic/aimpoint-acro-p-2/01-product.webp",
    "status": "in_production",
    "dataConfidence": 0.9,
    "updatedAt": "2026-09-07 00:00:00",
    "version": "e27e61b4b373f5a621d6ae5c67d521096131f2cf56300a1b2c3d4e5f6a7b8c9d",
    "provenance": {
      "sources": [
        "https://www.glock.com/en/products/pistols/g17-gen5"
      ],
      "sourceKinds": [
        {
          "url": "https://www.glock.com/en/products/pistols/g17-gen5",
          "kind": "manufacturer",
          "archivedFrom": null,
          "rank": 0,
          "tier": "authority",
          "basis": "declared",
          "evidence": null
        }
      ],
      "bestSourceKind": "manufacturer",
      "makerSource": "https://www.glock.com/en/products/pistols/g17-gen5",
      "evidence": "maker",
      "checks": {
        "raised": 3,
        "open": 0,
        "history": [
          {
            "task": "verify:firearms:glock-g17-gen5",
            "number": 1204,
            "check": "quality:firearm-weight-fits-its-size",
            "kind": "verify",
            "agent": "redback",
            "worker": "redback-046535",
            "status": "resolved",
            "decidedAt": "2026-09-25 07:42:39",
            "proposed": "Read https://us.glock.com/en/pistols/g17-gen5: agrees on barrel_length, weight.",
            "fields": [
              "barrel_length_mm",
              "weight_empty_g"
            ],
            "sources": [
              "https://us.glock.com/en/pistols/g17-gen5"
            ],
            "review": {
              "decision": "accepted",
              "at": null
            },
            "finding": {
              "why": "2.22 g/cm3 against a submachine-gun / carbon-steel median of 0.64; check the weight and the dimensions",
              "evidence": {
                "apparentDensity": 2.218,
                "categoryMedian": 0.635
              }
            }
          }
        ]
      },
      "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"
    },
    "description": null,
    "specs": {},
    "sources": [
      "string"
    ],
    "sourceUrl": null,
    "yearIntroduced": null,
    "requires": [
      [
        {
          "id": "stock:ak-100-folder",
          "name": "AK-100 series polymer side-folder trunnion (AK-74M pattern)",
          "kind": "stock_mount"
        }
      ]
    ],
    "provides": [
      {
        "id": "stock:ak-100-folder",
        "name": "AK-100 series polymer side-folder trunnion (AK-74M pattern)",
        "kind": "stock_mount",
        "position": "top"
      }
    ],
    "caliberRatings": [
      {
        "caliberId": "5-45x39mm",
        "caliberName": "5.45x39mm",
        "minBarrelLengthMm": 254,
        "fullAutoRated": false,
        "notes": null
      }
    ],
    "curatedFits": 0,
    "createdAt": "2026-09-07 00:00:00"
  }
}
```

### 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/attachments/magpul-zhukov-s-stock' \
  --header 'X-API-Key: your_key'
```

## List firearms an attachment fits

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

Auth: API key required  
Tier: Studio+

The reverse lookup: every catalogued firearm the attachment fits, with fit type, confidence and reason. Candidates come from the interface index and are confirmed by the same engine as the forward endpoint, so the two never disagree. `meta.universal` is true for an item with no requirements and no ratings (a sling), in which case the whole catalog is listed; `meta.truncated` is true when the candidate set hit its cap.

### Path parameters

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

### 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) |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object[] | One page of results |
| `data[].id` | string | Slug of the firearm the attachment fits. |
| `data[].name` | string | Display name, as the maker writes it. |
| `data[].fitType` | FitType \| null | How it fits: `direct` on the firearm own interface, `adapter` through the part named in `adapter`, or `curated` from an override on the attachment. Null when it does not fit. |
| `data[].source` | FitSource \| null | The weakest evidence behind the fit. |
| `data[].confidence` | number | How well evidenced this row is, 0 to 1. **A minimum, not an average**: an inferred row caps every fit computed through it, and `min_confidence` on the endpoint hides rows below a threshold. |
| `data[].reason` | string | Plain-language account of why it fits, safe to show in a UI. |
| `data[].via` | FitVia[] | The interfaces that satisfied the attachment requirements, with where each came from. |
| `data[].via[].position` | string | Where on the firearm the interface sits: `muzzle`, `rear`, `top`, `bottom`. |
| `data[].via[].standardId` | string | Id of the interface standard, kind-prefixed, e.g. `thread:1/2x28`. Ids are never renamed; new spellings become aliases. |
| `data[].via[].source` | InterfaceSource | Where an interface row on a firearm came from. `curated` is a person; `inferred` is the backfill, capped at 0.6 confidence; the inherited kinds came through the parent chain or a platform. |
| `data[].via[].adapterId` | string \| null | The adapter that bridges this fit, or null when none is needed. Threads, magazine wells and the caliber gate are never bridged. |
| `data[].adapter` | AdapterRef \| null | The adapter the fit goes through, when `fitType` is `adapter`. Null for a direct or curated fit. |
| `data[].manufacturer` | ManufacturerRef \| null | Who makes it. |
| `data[].category` | string \| null | Display name of the firearm category. |
| `data[].images` | InlineMediaItem[] | Renders and photographs of the firearm, ordered so `images[0]` is the frame to show at rest. |
| `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[].svgLineArtUrl` | string \| null | Line-art silhouette, or null where none has been drawn. |
| `data[].yearIntroduced` | integer | Year it entered production or service. Null where the date is disputed or unknown. |
| `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-74m",
      "name": "AK-74M",
      "fitType": "direct",
      "source": "curated",
      "confidence": 1,
      "reason": "Mounts on AK-100 series polymer side-folder trunnion (rear)",
      "via": [
        {
          "position": "rear",
          "standardId": "stock:ak-100-folder",
          "source": "curated",
          "adapterId": null
        }
      ],
      "adapter": {
        "id": "rs-regulate-ak-303m",
        "name": "RS Regulate AK-303M"
      },
      "manufacturer": {
        "id": "magpul",
        "name": "Magpul Industries"
      },
      "category": "Assault Rifle",
      "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
        }
      ],
      "svgLineArtUrl": "https://api.gunspec.io/v1/firearms/ak-74m/media/silhouette",
      "yearIntroduced": 1991
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "per_page": 20,
    "total": 8630,
    "totalPages": 432
  }
}
```

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

- Every row carries `source` and `confidence` with the same meaning as the forward endpoint: `source` is the weakest evidence behind the fit and `inferred` is unverified. Confirmed by the same engine, so the two never disagree about a pair.
- `meta.universal` is true for an item that requires nothing (a sling); every listed fit then carries `source: universal`.

### Example

```bash
curl --request GET \
  --url 'https://api.gunspec.io/v1/attachments/magpul-zhukov-s-stock/firearms' \
  --header 'X-API-Key: your_key'
```

## List interface standards

`GET /v1/interfaces`

Auth: API key required  
Tier: Explorer+

The controlled vocabulary of mount interfaces: rails, muzzle threads, optic footprints, stock and grip mounts, handguard mounts, magazine wells, side mounts, sight dovetails, bipod studs, sling points, trigger pockets and charging handles. Ids are kind-prefixed and stable.

### Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `kind` | string | no | One kind only |
| `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` | InterfaceStandard[] | The response payload. |
| `data[].id` | string | Kind-prefixed standard id, e.g. `thread:1/2x28`. URL-encode it when addressing `/v1/interfaces/{id}/firearms`. |
| `data[].kind` | string | What sort of interface it is: `thread`, `rail`, `mag`, `stock_mount`, `optic_plate`. |
| `data[].name` | string | Display name, as the maker writes it. |
| `data[].aliases` | string[] | JSON array of alternative spellings of this standard id, as a string. Ids are never renamed; a new spelling becomes an alias. |
| `data[].notes` | string \| null | Free-text notes that do not belong in a specific field. |

```json
{
  "success": true,
  "data": [
    {
      "id": "thread:1/2x28",
      "kind": "thread",
      "name": "1/2-28 UNEF right hand",
      "aliases": [
        "1/2x28",
        "1/2-28"
      ],
      "notes": null
    }
  ]
}
```

### 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/interfaces' \
  --header 'X-API-Key: your_key'
```

## List firearms exposing a standard

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

Auth: API key required  
Tier: Studio+

Every firearm that exposes the interface standard, directly or through a platform, confirmed through the resolver so a member that has declared `none` for the position is not listed. The id is URL-encoded: `thread%3A1%2F2x28`.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | Interface standard id. URL-encode it in the path: the colon and any slash must be escaped, so `thread:1/2x28` is sent as `thread%3A1%2F2x28` |

### 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) |

### Response 200 (application/json)

| Field | Type | Description |
| --- | --- | --- |
| `data` | object[] | One page of results |
| `data[].id` | string | Slug of the firearm exposing the standard. |
| `data[].name` | string | Display name, as the maker writes it. |
| `data[].position` | string | Where on the firearm the interface sits: `muzzle`, `rear`, `top`, `bottom`. |
| `data[].source` | InterfaceSource | Where an interface row on a firearm came from. `curated` is a person; `inferred` is the backfill, capped at 0.6 confidence; the inherited kinds came through the parent chain or a platform. |
| `data[].confidence` | number | How well evidenced this row is, 0 to 1. **A minimum, not an average**: an inferred row caps every fit computed through it, and `min_confidence` on the endpoint hides rows below a threshold. |
| `data[].manufacturer` | ManufacturerRef \| null | Who makes it. |
| `data[].category` | string \| null | Display name of the firearm category. |
| `data[].images` | InlineMediaItem[] | Renders and photographs of the firearm, ordered so `images[0]` is the frame to show at rest. |
| `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[].svgLineArtUrl` | string \| null | Line-art silhouette, or null where none has been drawn. |
| `data[].yearIntroduced` | integer | Year it entered production or service. Null where the date is disputed or unknown. |
| `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": "m4a1-carbine",
      "name": "M4A1 Carbine",
      "position": "muzzle",
      "source": "curated",
      "confidence": 1,
      "manufacturer": {
        "id": "magpul",
        "name": "Magpul Industries"
      },
      "category": "Assault Rifle",
      "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
        }
      ],
      "svgLineArtUrl": "https://api.gunspec.io/v1/firearms/m4a1-carbine/media/silhouette",
      "yearIntroduced": 1994
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "per_page": 20,
    "total": 42,
    "totalPages": 3
  }
}
```

### 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/interfaces/thread:1%2F2x28/firearms' \
  --header 'X-API-Key: your_key'
```

## List platforms

`GET /v1/platforms`

Auth: API key required  
Tier: Studio+

Groups of firearms that share mount interfaces by construction (AKM-pattern stamped receivers, mil-spec AR-15s, Gen5 9mm Glocks). A platform's default interfaces are inherited by every member unless the member declares its own for that position.

### 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 | Slug of the platform. |
| `data[].name` | string | Display name, as the maker writes it. |
| `data[].description` | string \| null | Prose summary of the record, where one has been written. |
| `data[].memberCount` | integer | How many firearms belong to the platform. |

```json
{
  "success": true,
  "data": [
    {
      "id": "ak-100",
      "name": "AK-100 series (AK-74M pattern)",
      "description": "AK-74M and its export family.",
      "memberCount": 11
    }
  ]
}
```

### 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/platforms' \
  --header 'X-API-Key: your_key'
```

## Get a platform

`GET /v1/platforms/{id}`

Auth: API key required  
Tier: Studio+

A platform with its default interfaces and its member firearms.

### Path parameters

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

### 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 | Slug of the platform. |
| `data.name` | string | Display name, as the maker writes it. |
| `data.description` | string \| null | Prose summary of the record, where one has been written. |
| `data.interfaces` | object[] | The mount interfaces resolved for this firearm, own rows first, then inherited. |
| `data.interfaces[].position` | string | Where on the firearm the interface sits: `muzzle`, `rear`, `top`, `bottom`. |
| `data.interfaces[].standardId` | string | Id of the interface standard, kind-prefixed, e.g. `thread:1/2x28`. Ids are never renamed; new spellings become aliases. |
| `data.interfaces[].name` | string | Display name, as the maker writes it. |
| `data.interfaces[].kind` | string | What the asset is: `silhouette`, `render`, `photo`, `schematic`, `model`. `/v1/firearms/{id}/media/{selector}` addresses an asset by this. |
| `data.interfaces[].confidence` | number | How well evidenced this row is, 0 to 1. **A minimum, not an average**: an inferred row caps every fit computed through it, and `min_confidence` on the endpoint hides rows below a threshold. |
| `data.interfaces[].notes` | string \| null | Free-text notes that do not belong in a specific field. |
| `data.members` | object[] | The firearms belonging to this platform, which inherit its default interfaces. |
| `data.members[].id` | string | Slug of the firearm. |
| `data.members[].name` | string | Display name, as the maker writes it. |

```json
{
  "success": true,
  "data": {
    "id": "ak-100",
    "name": "AK-100 series (AK-74M pattern)",
    "description": "AK-74M and its export family.",
    "interfaces": [
      {
        "position": "rear",
        "standardId": "stock:ak-100-folder",
        "name": "AK-100 series polymer side-folder trunnion (AK-74M pattern)",
        "kind": "stock_mount",
        "confidence": 1,
        "notes": null
      }
    ],
    "members": [
      {
        "id": "ak-74m",
        "name": "AK-74M"
      }
    ]
  }
}
```

### 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/platforms/ak-100' \
  --header 'X-API-Key: your_key'
```
