gunspec-io Categories API

Firearm categories (pistol, rifle, shotgun, etc.)

Operations 2

GET /v1/categories List categories #
GET /v1/categories/{slug}/firearms List firearms in a category #

Work with this as data

Every API here is available over the APIs.io API and to AI agents over MCP.

MCP server

One button, every client — Claude, Cursor, VS Code and the rest.

https://apis.io/mcp

Tools for apis

7 MCP tools reach this
  • find_apisBrowse and filter every API in the catalog.
  • get_api_artifactsOne API's artifacts, grouped by type.
  • get_openapiThe primary OpenAPI for this API.
  • find_similar_apisAPIs that look like this one.
  • apis_io_searchSTART HERE — APIs, providers and tags for one query, each with its total.
  • resolveTurn a domain, URL or GitHub org into the provider it belongs to.
  • find_cohortsEvery scored population of providers in the catalog.
All 92 tools →

Call it yourself

curl for this page
This API
curl "https://apis.io/api/v1/apis/gunspec-io:gunspec-io-categories-api"
All apis
curl "https://apis.io/api/v1/apis?limit=25"

Discovery needs no key. Ratings and market analysis are Pro.

Get an API key

Free tier, no form to fill in. Signing in shares your email address with us — we store it to create your key and to recognise you if you sign in with another provider. See our Privacy Policy and Terms.

A second provider on the same verified email joins the account you already have.

OpenAPI Specification

gunspec-io-categories-api-openapi.yml Raw ↑
openapi: 3.2.0
info:
  title: GunSpec.io Categories API
  version: 1.1.0
  description: 'Comprehensive firearms specification database API. ## Authentication


    All `/v1` endpoints accept an API key via **`X-API-Key`** header or **`Authorization: Bearer `**. Some endpoints are accessible without a key (anonymous tier), while others require a minimum subscription tier. The tier required by each endpoint is stated in its description. ## Plans and limits


    | Tier | Price | Requests/month | Daily cap | Rate limit | Max page depth |

    |------|-------|----------------|-----------|------------|----------------|

    | Explorer | $0 | 200 | 50 | 10/min | 5 |

    | Builder | $29/mo | 25,000 | 5,000 | 60/min | 25 |

    | Studio | $79/mo | 150,000 | 10,000 | 120/min | Unlimited |

    | Enterprise | $249/mo | 1,000,000 | 50,000 | 300/min | Unlimited |


    **Explorer**: For evaluation and testing

    - 200 requests/month

    - 10 requests/min

    - Try any endpoint up to Studio with 50 one-time credits

    - Basic fields

    - 5 pages max


    **Builder**: For indie developers and side projects

    - 25,000 requests/month

    - 60 requests/min

    - Full specs

    - Compare endpoint

    - 25 pages max


    **Studio**: For production applications

    - 150,000 requests/month

    - 120 requests/min

    - All endpoints

    - Unlimited pages

    - Priority support


    **Enterprise**: For high-volume use, and for retailers advertising stock across the catalog

    - 1,000,000 requests/month

    - 300 requests/min

    - All endpoints

    - Unlimited pages

    - Seller listing on every part and firearm you stock

    - Your logo, price and stock, linking to your own checkout

    - Listed in the API too, so partner sites carry your offers

    - Price in your own currency, shown to the regions you ship to

    - Dedicated support

    - SLA


    Anonymous requests (no API key) are treated as Explorer with a 50 request/day cap. ## Rate Limiting


    Three limits can refuse a call with `429`. The per-minute limit returns `RATE_LIMITED` with `Retry-After: 60`. The daily limit, counted per key, returns `DAILY_CAP_EXCEEDED`: nothing is served until it resets at midnight UTC, `Retry-After` is the seconds left until then (at most 86,400), and the body carries `error.limit`, `error.resetsAt` (an ISO 8601 instant) and `error.retryAfter`. The monthly allowance, which is the plan''s requests per month and belongs to the whole account (every key draws on one pool), returns `MONTHLY_CAP_EXCEEDED` in the same shape, with `Retry-After` up to 2,678,400 and the reset at midnight UTC on the first of next month. Only calls that were served spend the month: a call a limit refused, and a `304`, do not. Whichever limit is reached first refuses, and when both are, the monthly one is the one reported because it lasts longer. What is left of the **day** and of the **month** is sent on every response, so a script never has to ask. Every `/v1` response on a plan with a daily limit carries `X-Daily-Limit`, `X-Daily-Remaining` and `X-Daily-Reset` (the UTC midnight it rolls over at, as an ISO 8601 instant), a response to a call made with a key also carries `X-Monthly-Limit`, `X-Monthly-Remaining` and `X-Monthly-Reset` (midnight UTC on the first of next month), and a call through the hosted MCP server carries `X-Daily-MCP-*` beside them. A plan with no daily limit sends none of them. `Remaining` is what is left after the call that returned it, so the response that reports `0` is the last one that will be served: stop there and resume at `Reset`. Treat `Remaining` as a figure to pace against rather than settle on: it is served from a short cache while you are well under the limit and read live once you are near it, so it can read high by about a minute of traffic early in the day. `GET /v1/me/usage` is the figure to reconcile against, and the one that shows every key at once. Stop at the first `DAILY_CAP_EXCEEDED`. A call made after it is refused too and still counted, and a free key that keeps calling after being refused is paused (`403` with `KEY_ON_HOLD` and a `Retry-After`) until the limit resets or the account moves to a paid plan. Signing in, the portal and billing are not affected by a pause.'
  contact:
    name: GunSpec.io
    url: https://gunspec.io
  license:
    name: Proprietary
    url: https://gunspec.io/terms
servers:
- url: https://api.gunspec.io
  description: This API
tags:
- name: Categories
  description: Firearm categories (pistol, rifle, shotgun, etc.)
paths:
  /v1/categories:
    get:
      tags:
      - Categories
      summary: List categories
      description: 'Returns every firearm category that holds at least one firearm. Empty categories are omitted.


        Available on every plan. Requires an API key; Explorer keys receive a reduced field set.'
      operationId: listCategories
      x-gunspec-tier: explorer
      security:
      - ApiKeyHeader: []
      - BearerAuth: []
      responses:
        '200':
          description: List categories
          headers:
            X-Daily-Limit:
              $ref: '#/components/headers/DailyLimit'
            X-Daily-Remaining:
              $ref: '#/components/headers/DailyRemaining'
            X-Daily-Reset:
              $ref: '#/components/headers/DailyReset'
            X-Monthly-Limit:
              $ref: '#/components/headers/MonthlyLimit'
            X-Monthly-Remaining:
              $ref: '#/components/headers/MonthlyRemaining'
            X-Monthly-Reset:
              $ref: '#/components/headers/MonthlyReset'
            ETag:
              description: 'A hash of this exact response body, for this plan. Send it back as `If-None-Match` to revalidate cheaply. It is per-response rather than per-record: two plans receive different fields for the same record and therefore different tags. To compare a record across plans, use the `version` field on the record itself.'
              schema:
                type: string
                example: '"3f9c1a7d5b2e4088"'
            Cache-Control:
              description: How long this response may be kept. This endpoint sends `public, max-age=3600, s-maxage=86400, stale-while-revalidate=3600`.
              schema:
                type: string
                example: public, max-age=3600, s-maxage=86400, stale-while-revalidate=3600
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                    description: Always true on a successful response. Every error carries `false` and an `error` object instead.
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Category'
                    description: The response payload.
                required:
                - success
                - data
        '304':
          description: Your copy is current. No body is sent, and this response does not count toward the daily request allowance.
          headers:
            ETag:
              description: 'A hash of this exact response body, for this plan. Send it back as `If-None-Match` to revalidate cheaply. It is per-response rather than per-record: two plans receive different fields for the same record and therefore different tags. To compare a record across plans, use the `version` field on the record itself.'
              schema:
                type: string
                example: '"3f9c1a7d5b2e4088"'
            Cache-Control:
              description: The lifetime that applied to the response you already hold.
              schema:
                type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
      parameters:
      - name: If-None-Match
        in: header
        required: false
        schema:
          type: string
        description: 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.
  /v1/categories/{slug}/firearms:
    get:
      tags:
      - Categories
      summary: List firearms in a category
      description: 'Returns the firearms belonging to a category.


        Available on every plan. Requires an API key; Explorer keys receive a reduced field set.'
      operationId: getCategoryFirearms
      x-gunspec-tier: explorer
      security:
      - ApiKeyHeader: []
      - BearerAuth: []
      parameters:
      - name: slug
        in: path
        required: true
        schema:
          type: string
          pattern: ^[a-z0-9-]+$
        description: Category slug
        example: pistol
      - $ref: '#/components/parameters/page'
      - $ref: '#/components/parameters/per_page'
      responses:
        '200':
          description: List firearms in a category
          headers:
            X-Daily-Limit:
              $ref: '#/components/headers/DailyLimit'
            X-Daily-Remaining:
              $ref: '#/components/headers/DailyRemaining'
            X-Daily-Reset:
              $ref: '#/components/headers/DailyReset'
            X-Monthly-Limit:
              $ref: '#/components/headers/MonthlyLimit'
            X-Monthly-Remaining:
              $ref: '#/components/headers/MonthlyRemaining'
            X-Monthly-Reset:
              $ref: '#/components/headers/MonthlyReset'
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                    description: Always true on a successful response. Every error carries `false` and an `error` object instead.
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/CategoryFirearmListItem'
                    description: One page of results. Use `pagination` to walk the rest.
                  pagination:
                    $ref: '#/components/schemas/Pagination'
                required:
                - success
                - data
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalError'
components:
  schemas:
    CategoryFirearmListItem:
      type: object
      properties:
        id:
          type: string
          example: glock-g17-gen5
          description: URL-safe slug identifying the firearm, e.g. `glock-g17-gen5`.
        name:
          type: string
          example: Glock 17 Gen5
          description: Display name, as the maker writes it.
        manufacturerId:
          type: string
          example: glock
          description: Slug of the manufacturer that makes it. Resolve against `/v1/manufacturers/{id}`.
        yearIntroduced:
          type:
          - integer
          - 'null'
          example: 2017
          description: Year it entered production or service. Null where the date is disputed or unknown.
        status:
          anyOf:
          - $ref: '#/components/schemas/FirearmStatus'
          - type: 'null'
          example: in_production
          description: '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.'
        actionType:
          type:
          - string
          - 'null'
          example: short_recoil
          x-gunspec-vocabulary:
          - short_recoil
          - bolt_action
          - blowback
          - break_action
          - gas_operated
          - gas_operated_long_stroke
          - gas_operated_direct_impingement
          - revolving
          - gas_operated_short_stroke
          - muzzleloader
          - pump_action
          - lever_action
          - double_action
          - falling_block
          - roller_delayed_blowback
          - single_action
          - gas_operated_short_stroke_piston
          - single_action_revolver
          - double_action_revolver
          - inertia_operated
          - recoil_operated
          - delayed_blowback
          - long_recoil
          - gas_operated_rotating_bolt
          - breech_loading
          - rolling_block
          - straight_pull
          - trapdoor
          - spring_piston
          - hinged_breechblock
          - lever_delayed_blowback
          - blow_forward
          - electric_gatling
          - double_action_only
          - rotating_barrel
          - pneumatic
          - gas_operated_dual_mode
          - delayed_blowback_rotating_barrel
          - gas_delayed_blowback
          - radial_delayed_blowback
          - short_recoil_rotating_barrel
          - gas_operated_balanced_automatics
          - gas_operated_balanced_recoil
          - gas_operated_blow_forward
          - gas_operated_open_bolt
          - gas_operated_tilting_bolt
          description: '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.'
        countryOfOrigin:
          type:
          - string
          - 'null'
          example: AT
          description: ISO 3166-1 alpha-2 code of where it was designed, e.g. `AT`.
        svgLineArtUrl:
          type:
          - string
          - 'null'
          description: Line-art silhouette, or null where none has been drawn.
        model3dUrl:
          type:
          - string
          - 'null'
          description: GLB model, or null where none is on file.
        images:
          type: array
          description: Every image this firearm has, silhouette first. Empty when none are on file.
          items:
            $ref: '#/components/schemas/InlineMediaItem'
    MediaKind:
      type: string
      enum:
      - silhouette
      - render
      - photo
      - schematic
      - model
      description: What kind of asset a media row is.
    ErrorResponse:
      type: object
      properties:
        success:
          type: boolean
          example: false
          description: Always false on an error.
        error:
          type: object
          properties:
            code:
              type: string
              example: NOT_FOUND
              description: Machine-readable error code, e.g. `NOT_FOUND`, `RATE_LIMITED`, `SUBSCRIPTION_REQUIRED`. Branch on this, never on `message`.
            reason:
              type: string
              enum:
              - INVALID_PARAMETER
              - INVALID_JSON
              - INVALID_REQUEST
              - AUTH_REQUIRED
              - KEY_MISSING
              - KEY_INVALID
              - KEY_DISABLED
              - KEY_EXPIRED
              - SIGN_IN_REQUIRED
              - SESSION_EXPIRED
              - ACCOUNT_NOT_FOUND
              - LINK_EXPIRED
              - PROXY_CREDENTIAL_INVALID
              - WEBHOOK_SIGNATURE_INVALID
              - PLAN_REQUIRED
              - ACCOUNT_SUSPENDED
              - KEY_ON_HOLD
              - KEY_NOT_LINKED_TO_ACCOUNT
              - KEY_MCP_ONLY
              - KEY_NOT_LINKED_TO_SHOP
              - SHOP_UNDER_REVIEW
              - NOT_OWNER
              - PURCHASE_REQUIRED
              - LIMIT_REACHED
              - PAGINATION_DEPTH_EXCEEDED
              - ADMIN_REQUIRED
              - READ_ONLY_SESSION
              - ORIGIN_BLOCKED
              - CLIENT_BLOCKED
              - ACTION_NOT_ALLOWED
              - RESOURCE_NOT_FOUND
              - ROUTE_NOT_FOUND
              - CONFLICT
              - PAYLOAD_TOO_LARGE
              - RATE_LIMITED
              - DAILY_CAP_EXCEEDED
              - MONTHLY_CAP_EXCEEDED
              - MCP_DAILY_CAP_EXCEEDED
              - PAGINATION_BURST
              - REPORT_RATE_LIMITED
              - INTERNAL_ERROR
              - DEPENDENCY_UNAVAILABLE
              - MAINTENANCE
              example: RESOURCE_NOT_FOUND
              description: 'Which situation, within `code`. Stable and machine-readable: branch on this, never on `message`. Each error response lists the reasons it can carry, and the error reference says what to do about each.'
            message:
              type: string
              example: 'Firearm not found: unknown-id'
              description: Human-readable account of what went wrong. The wording may change; treat it as text for a log or a person, not as a value to match on.
            details:
              type: object
              description: 'Structured context for the reason: the invalid fields for `INVALID_PARAMETER`, `requiredTier` for `PLAN_REQUIRED`.'
            limit:
              type: integer
              example: 50
              description: 'On `DAILY_CAP_EXCEEDED`, `MCP_DAILY_CAP_EXCEEDED` and `MONTHLY_CAP_EXCEEDED` only: the limit that was reached. The daily limits are per key; the monthly one is the plan''s allowance for the whole account.'
            resetsAt:
              type: string
              example: '2026-10-03T00:00:00.000Z'
              description: 'On `DAILY_CAP_EXCEEDED`, `MCP_DAILY_CAP_EXCEEDED` and `MONTHLY_CAP_EXCEEDED` only: when the limit returns to zero, ISO 8601. The next midnight UTC for the daily limits (the same instant as `X-Daily-Reset`), midnight UTC on the first of next month for the monthly one (`X-Monthly-Reset`). Nothing sent before then will be served.'
            retryAfter:
              type: integer
              example: 14400
              description: 'On `DAILY_CAP_EXCEEDED`, `MCP_DAILY_CAP_EXCEEDED` and `MONTHLY_CAP_EXCEEDED` only: whole seconds until `resetsAt`, the same figure as the `Retry-After` header.'
            request_id:
              type: string
              example: req_01j9x3k2m8
              description: 'Quote this to support: it finds the request in our logs.'
          required:
          - code
          - reason
          - message
          description: The failure, as `{ code, message }`. Branch on `code`, never on the message text.
      required:
      - success
      - error
    Category:
      type: object
      properties:
        id:
          type: string
          example: pistol
          description: URL-safe slug identifying the category, e.g. `pistol`.
        name:
          type: string
          example: Pistol
          description: Display name, as the maker writes it.
        description:
          type:
          - string
          - 'null'
          example: Handguns designed to be fired with one or two hands
          description: Prose summary of the record, where one has been written.
    Pagination:
      type: object
      description: Page-size is reported under both `per_page` (matching the request parameter) and `limit` (retained for existing integrations). They always carry the same value. `total` and `totalPages` are omitted for Explorer and anonymous callers.
      properties:
        page:
          type: integer
          example: 1
          description: The page this response is, 1-based.
        per_page:
          type: integer
          example: 20
          description: Page size. Same value as `limit`.
        limit:
          type: integer
          example: 20
          description: Page size. Alias of `per_page`.
        total:
          type: integer
          example: 42
          description: Total records matching the query across every page.
        totalPages:
          type: integer
          example: 3
          description: How many pages the result set spans at the current `per_page`.
      required:
      - page
      - per_page
      - limit
    FirearmStatus:
      type: string
      enum:
      - in_production
      - discontinued
      - out_of_production
      - in_service
      - limited_production
      - prototype
      description: 'Production status of a firearm. Closed: these are every value the catalog holds.'
    InlineMediaItem:
      type: object
      description: One image attached to a firearm, inlined on list-shaped responses so a client can render a gallery without a second request. `url` is absolute and directly fetchable. For the full envelope, including credits, licence, dimensions and data-URI encoding, use `/v1/firearms/{id}/media`.
      properties:
        id:
          type: integer
          example: 2572
          description: Autoincrementing image id.
        url:
          type: string
          example: https://api.gunspec.io/v1/firearms/ak-47/images/2572
          description: Absolute, directly fetchable URL.
        kind:
          $ref: '#/components/schemas/MediaKind'
          example: render
        alt:
          type:
          - string
          - 'null'
          example: AK-47 right profile
          description: Alt text, where one has been written. Null otherwise, so do not render an empty string.
        width:
          type:
          - integer
          - 'null'
          example: 1536
          description: Pixel width, where it is known.
        height:
          type:
          - integer
          - 'null'
          example: 1024
          description: Pixel height, where it is known.
      required:
      - id
      - url
      - kind
  headers:
    RetryAfter:
      description: Whole seconds to wait before retrying. A per-minute refusal sends 60. A daily refusal sends the seconds left until the next midnight UTC, at most 86,400. A monthly refusal sends the seconds left until midnight UTC on the first of next month, at most 2,678,400. Waiting that long is the whole answer, since nothing is served before the reset.
      schema:
        type: integer
        example: 60
    MonthlyReset:
      description: 'When the monthly allowance returns to zero: midnight UTC on the first of next month, as an ISO 8601 instant.'
      schema:
        type: string
        format: date-time
        example: '2026-11-01T00:00:00.000Z'
    DailyReset:
      description: 'When the daily counters return to zero: the next midnight UTC, as an ISO 8601 instant. Parse it as a date to know how long until the allowance is back.'
      schema:
        type: string
        format: date-time
        example: '2026-10-03T00:00:00.000Z'
    MonthlyRemaining:
      description: 'Requests the account has left this month after this one, never below zero: the response to the last request that will be served reports 0, and the next is refused with a 429. Only calls that were served spend it: a call a limit refused, and a 304, do not. It is served from a short cache while you are well under the allowance and read live near it, so it can read high by about a minute of traffic. `GET /v1/me/usage` is the figure to reconcile against.'
      schema:
        type: integer
        example: 22616
    DailyLimit:
      description: 'Requests this key may make per UTC day on its plan: the daily limit, per key. Sent on every `/v1` response for a plan that has one, and absent for a plan with no daily ceiling.'
      schema:
        type: integer
        example: 50
    DailyRemaining:
      description: 'Requests this key has left today after this one, never below zero: the response to the last request that will be served reports 0, and the next request is refused with a 429. Stop at zero rather than spend calls the limit will refuse. A 304 spends nothing, so it reports what was left before it. It is served from a short cache while you are well under the limit and read live once you are near it, so it can read high by about a minute of traffic early in the day; `GET /v1/me/usage` is the figure to reconcile against.'
      schema:
        type: integer
        example: 37
    MonthlyLimit:
      description: 'Requests the plan allows per UTC month, for the whole account: every key draws on one pool. Sent on every `/v1` response made with a key, and absent for a keyless call, which has the daily limit only.'
      schema:
        type: integer
        example: 25000
  responses:
    InternalError:
      x-gunspec-reasons:
      - INTERNAL_ERROR
      description: Internal server error
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            success: false
            error:
              code: INTERNAL_ERROR
              reason: INTERNAL_ERROR
              message: An unexpected error occurred
    Unauthorized:
      x-gunspec-reasons:
      - AUTH_REQUIRED
      - KEY_MISSING
      - KEY_INVALID
      - KEY_DISABLED
      - KEY_EXPIRED
      - SIGN_IN_REQUIRED
      - SESSION_EXPIRED
      - ACCOUNT_NOT_FOUND
      - LINK_EXPIRED
      - PROXY_CREDENTIAL_INVALID
      - WEBHOOK_SIGNATURE_INVALID
      description: Missing or invalid API key
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            success: false
            error:
              code: UNAUTHORIZED
              reason: KEY_INVALID
              message: Invalid API key
    BadRequest:
      x-gunspec-reasons:
      - INVALID_PARAMETER
      - INVALID_JSON
      - INVALID_REQUEST
      description: Validation error
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            success: false
            error:
              code: VALIDATION_ERROR
              reason: INVALID_PARAMETER
              message: Invalid query parameters
    TooManyRequests:
      x-gunspec-reasons:
      - RATE_LIMITED
      - DAILY_CAP_EXCEEDED
      - MONTHLY_CAP_EXCEEDED
      - MCP_DAILY_CAP_EXCEEDED
      - PAGINATION_BURST
      - REPORT_RATE_LIMITED
      description: 'A limit was reached. `RATE_LIMITED` is the per-minute limit: wait `Retry-After` seconds (a minute) and retry. `DAILY_CAP_EXCEEDED` is the plan''s daily limit, per key: nothing will be served until the counters return to zero at midnight UTC. `MONTHLY_CAP_EXCEEDED` is the plan''s monthly allowance, for the whole account: nothing will be served until midnight UTC on the first of next month, or until the plan changes. For both, `Retry-After` is the seconds until then and `error.resetsAt` says when as an instant. Calls made after a refusal are refused too and do not spend the month, and a free key that keeps calling is paused, so stop at the first one and resume at the reset.'
      headers:
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
        X-Daily-Limit:
          $ref: '#/components/headers/DailyLimit'
        X-Daily-Remaining:
          $ref: '#/components/headers/DailyRemaining'
        X-Daily-Reset:
          $ref: '#/components/headers/DailyReset'
        X-Monthly-Limit:
          $ref: '#/components/headers/MonthlyLimit'
        X-Monthly-Remaining:
          $ref: '#/components/headers/MonthlyRemaining'
        X-Monthly-Reset:
          $ref: '#/components/headers/MonthlyReset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            minute:
              summary: The per-minute limit
              value:
                success: false
                error:
                  code: RATE_LIMITED
                  reason: RATE_LIMITED
                  message: Rate limit exceeded. Try again later.
            monthly:
              summary: The monthly allowance
              value:
                success: false
                error:
                  code: MONTHLY_CAP_EXCEEDED
                  reason: MONTHLY_CAP_EXCEEDED
                  message: 'Monthly request limit (200) reached for your plan (explorer). Resets at 2026-11-01T00:00:00.000Z (midnight UTC on the 1st). Choose a larger plan to continue now. Stop calling until then: a key that keeps calling after this is paused.'
                  limit: 200
                  resetsAt: '2026-11-01T00:00:00.000Z'
                  retryAfter: 2592000
            daily:
              summary: The daily limit
              value:
                success: false
                error:
                  code: DAILY_CAP_EXCEEDED
                  reason: DAILY_CAP_EXCEEDED
                  message: 'Daily request limit (50) exceeded for your tier (explorer). Resets at 2026-10-03T00:00:00.000Z (midnight UTC). Stop calling until then: a key that keeps calling after this is paused.'
                  limit: 50
                  resetsAt: '2026-10-03T00:00:00.000Z'
                  retryAfter: 14400
    Forbidden:
      x-gunspec-reasons:
      - PLAN_REQUIRED
      - ACCOUNT_SUSPENDED
      - KEY_ON_HOLD
      - KEY_NOT_LINKED_TO_ACCOUNT
      - KEY_MCP_ONLY
      - KEY_NOT_LINKED_TO_SHOP
      - SHOP_UNDER_REVIEW
      - NOT_OWNER
      - PURCHASE_REQUIRED
      - LIMIT_REACHED
      - PAGINATION_DEPTH_EXCEEDED
      - ADMIN_REQUIRED
      - READ_ONLY_SESSION
      - ORIGIN_BLOCKED
      - CLIENT_BLOCKED
      - ACTION_NOT_ALLOWED
      description: 'The credential is valid and the caller is not permitted. Three causes, none of which reissuing a key changes: the plan is below the endpoint''s tier, the account is suspended, or the key is not attached to the thing being acted on, meaning a user account for `/v1/me` or a shop for `/v1/vendor`. Distinct from 401, which means the credential is missing, malformed or expired and a new one can work.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            success: false
            error:
              code: FORBIDDEN
              reason: PLAN_REQUIRED
              message: 'This endpoint requires ''studio'' tier or above. Your tier: ''builder'''
              details:
                requiredTier: studio
                currentTier: builder
    NotFound:
      x-gunspec-reasons:
      - RESOURCE_NOT_FOUND
      - ROUTE_NOT_FOUND
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            success: false
            error:
              code: NOT_FOUND
              reason: RESOURCE_NOT_FOUND
              message: 'Firearm not found: unknown-id'
  parameters:
    page:
      name: page
      in: query
      schema:
        type: integer
        minimum: 1
        maximum: 10000
        default: 1
      description: Page number, from 1 to 10,000
    per_page:
      name: per_page
      in: query
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20
      description: Items per page (max 100)
  securitySchemes:
    ApiKeyHeader:
      type: apiKey
      in: header
      name: X-API-Key
      description: API key passed via X-API-Key header
    BearerAuth:
      type: http
      scheme: bearer
      description: 'API key passed via Authorization: Bearer header'