Every API here is available over the APIs.io API and to AI agents over MCP.
openapi: 3.2.0
info:
title: GunSpec.io Collections 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: Collections
description: Publicly shared user collections
paths:
/v1/collections/{shareId}:
get:
tags:
- Collections
summary: Get a shared collection
description: 'Returns a user collection that has been shared publicly, together with the firearms in it. The 10-character share id is the only credential; unshared collections are not reachable here.
No API key required.'
operationId: getPublicCollection
x-gunspec-tier: public
security:
- {}
- ApiKeyHeader: []
- BearerAuth: []
parameters:
- name: shareId
in: path
required: true
schema:
type: string
pattern: ^[a-zA-Z0-9]+$
description: 10-character public share id
example: a1b2c3d4e5
responses:
'200':
description: Get a shared collection
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: object
properties:
id:
type: string
example: col_01H8
description: Internal id of the collection. Not a credential; the share id is.
name:
type: string
example: Service pistols
description: Display name, as the maker writes it.
description:
type:
- string
- 'null'
example: Standard-issue sidearms
description: Prose summary of the record, where one has been written.
shareId:
type: string
example: a1b2c3d4e5
description: The 10-character public share id, echoing the path parameter.
itemCount:
type: integer
example: 12
description: How many firearms the collection holds.
createdAt:
type: string
example: '2026-01-15T12:00:00.000Z'
description: When the record was first added, `YYYY-MM-DD HH:MM:SS` in UTC.
items:
type: array
items:
$ref: '#/components/schemas/FirearmListItem'
description: The rows this response carries.
description: The response payload.
required:
- success
- data
'400':
$ref: '#/components/responses/BadRequest'
'404':
$ref: '#/components/responses/NotFound'
'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'
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
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
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
schemas:
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
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
FirearmListItem:
type: object
properties:
id:
type: string
example: glock-g17-gen5
description: 'URL-safe slug identifying the firearm, e.g. `glock-g17-gen5`. Stable: it is what a mirror keys on.'
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}`.
categoryId:
type: string
example: pistol
description: Slug of the category it belongs to. Resolve against `/v1/categories`.
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.'
countryOfOrigin:
type:
- string
- 'null'
example: AT
description: ISO 3166-1 alpha-2 code of where it was designed, e.g. `AT`.
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.'
weightEmptyG:
type:
- number
- 'null'
example: 625
description: Unloaded weight in grams, without magazine.
barrelLengthMm:
type:
- number
- 'null'
example: 114
description: Barrel length in millimetres, measured from the breech face.
images:
type: array
description: Every image this firearm has, silhouette first. Empty when none are on file.
items:
$ref: '#/components/schemas/InlineMediaItem'
createdAt:
type: string
example: '2026-09-06 23:55:07'
description: When the record was first added, `YYYY-MM-DD HH:MM:SS` in UTC.
svgLineArtUrl:
type:
- string
- 'null'
example: https://api.gunspec.io/v1/firearms/glock-g17-gen5/media/silhouette
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.
favoriteCount:
type: integer
example: 0
description: How many accounts have favorited this record.
updatedAt:
type: string
example: '2026-09-10 06:42:19'
description: 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.
version:
type:
- string
- 'null'
example: a3f1c2d4e5b6a7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2
description: '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.'
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
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'
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
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'