Every API here is available over the APIs.io API and to AI agents over MCP.
openapi: 3.2.0
info:
title: GunSpec.io Releases 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: Releases
description: New firearm models, each with the dates it was announced and first read, its alternate names and the sources that name it
paths:
/v1/releases:
get:
tags:
- Releases
summary: List new firearm models
description: 'Returns new firearm models, newest announcement first, each with the date it was announced, the date GunSpec first read it, its alternate names and the pages that name it. A model appears only after research has read a source a reader can cite (the maker, a standards body, a government, a reference work or the press) and quoted the words that name it; a headline alone never makes a row. `status` says whether the catalog holds a record yet (`catalogued`, with `catalogueId`) or not (`announced`). Filter by day with `since` and `until`: they apply to `announcedAt`, or to the day GunSpec first read the model with `order=consumed`. A model with no dated source counts from the day it was first read.
Available on every plan. Requires an API key; Explorer keys receive a reduced field set.'
operationId: listReleases
x-gunspec-tier: explorer
x-gunspec-sample:
- since
security:
- ApiKeyHeader: []
- BearerAuth: []
parameters:
- $ref: '#/components/parameters/page'
- $ref: '#/components/parameters/per_page'
- name: since
in: query
schema:
type: string
maxLength: 10
description: Only models on or after this day (`YYYY-MM-DD`)
example: '2026-09-01'
- name: until
in: query
schema:
type: string
maxLength: 10
description: Only models on or before this day (`YYYY-MM-DD`)
- name: maker
in: query
schema:
type: string
maxLength: 100
description: Filter by catalog manufacturer id
example: glock
- name: status
in: query
schema:
type: string
enum:
- announced
- catalogued
description: Filter by whether the catalog holds a record yet
- name: q
in: query
schema:
type: string
maxLength: 100
description: Search the model name, its alternate names and the maker
- name: order
in: query
schema:
type: string
enum:
- announced
- consumed
default: announced
description: Sort and date-filter by the announcement date, or by the day GunSpec first read the model
responses:
'200':
description: List new firearm models
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:
type: object
properties:
id:
type: string
example: 7c3f2a90-5e1b-4d8a-9b6c-2f4e8a1d0c35
description: Stable id of the release row.
slug:
type: string
example: northfield-arms-nf-9-compact
description: 'The id the catalog gives the model once it holds a record. Informational: it can change if research settles a different name, so key on `id`.'
name:
type: string
example: Northfield Arms NF-9 Compact
description: The model's name as the maker writes it, settled by research from a page that states it.
alternateNames:
type: array
items:
type: string
example: NF-9C
description: Other names the sources give the model. Each appears on a page research read; none is inferred.
manufacturer:
type:
- object
- 'null'
description: The maker. `id` is the catalog manufacturer id, or null when the catalog holds no record of the maker yet.
properties:
id:
type:
- string
- 'null'
example: northfield-arms
description: Catalog manufacturer id.
name:
type: string
example: Northfield Arms
description: Maker name.
required:
- id
- name
status:
type: string
enum:
- announced
- catalogued
example: announced
description: Whether the catalog holds a record for the model yet.
catalogueId:
type:
- string
- 'null'
example: null
description: The firearm id once `status` is `catalogued`, for `GET /v1/firearms/{id}`. Null before.
announcedAt:
type:
- string
- 'null'
example: '2026-09-18'
description: The date (`YYYY-MM-DD`) of the earliest source a reader can cite that names the model. Null when no source carried a date.
releasedAt:
type:
- string
- 'null'
example: 2026-11
description: 'When a source says the model ships or goes on sale, at the precision it gave: `YYYY`, `YYYY-MM` or `YYYY-MM-DD`. Null when no source states a date. "Spring 2027" is not a date and is never turned into one.'
releasedPrecision:
type:
- string
- 'null'
enum:
- year
- month
- day
- null
example: month
description: How much of `releasedAt` the source gave.
firstSeenAt:
type: string
example: '2026-09-19T04:15:02Z'
description: 'When GunSpec first read the model (UTC): the date we consumed it. Always present.'
updatedAt:
type: string
example: '2026-09-21T09:30:11Z'
description: When the row last changed (UTC).
bestSourceKind:
type:
- string
- 'null'
enum:
- manufacturer
- standards_body
- government
- peer_reviewed
- reference
- aggregator
- press
- retailer
- community
- other
- null
example: manufacturer
description: 'The strongest kind among `sources`, strongest first: the maker''s own page, a standards body, a government, a reference work, then press.'
sources:
type: array
items:
type: object
properties:
url:
type: string
example: https://www.example.com/news/northfield-nf-9-compact
description: A page that names the model. Open it to check the claim.
kind:
type: string
enum:
- manufacturer
- standards_body
- government
- peer_reviewed
- reference
- aggregator
- press
- retailer
- community
- other
example: manufacturer
description: What kind of page it is (`SourceKind`).
publisher:
type:
- string
- 'null'
example: Northfield Arms
description: Who published it, when known.
publishedAt:
type:
- string
- 'null'
example: '2026-09-18'
description: The page's own date (`YYYY-MM-DD`), when it carried one.
description: One cited page.
description: The pages that name the model, oldest first. Every release has at least one of a citable kind.
description: 'A new firearm model: its name and alternate names, the dates it was announced and first read, and the sources that name it.'
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'
'429':
$ref: '#/components/responses/TooManyRequests'
'500':
$ref: '#/components/responses/InternalError'
components:
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
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
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
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:
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
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
schemas:
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
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
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'