Every API here is available over the APIs.io API and to AI agents over MCP.
openapi: 3.2.0
info:
title: GunSpec.io Countries 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: Countries
description: Countries of origin and national arsenals
paths:
/v1/countries:
get:
tags:
- Countries
summary: List countries of origin
description: 'Returns every country that has adopted a catalogued firearm, ranked by how many distinct firearms it operates.
Available on every plan. Requires an API key; Explorer keys receive a reduced field set.'
operationId: listCountries
x-gunspec-tier: explorer
security:
- ApiKeyHeader: []
- BearerAuth: []
responses:
'200':
description: List countries of origin
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:
code:
type: string
example: US
description: ISO 3166-1 alpha-2 country code.
firearm_count:
type: integer
example: 214
description: Distinct catalogued firearms this country operates.
description: The response payload.
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'
/v1/countries/{code}/arsenal:
get:
tags:
- Countries
summary: Get a country arsenal
description: 'Returns the firearms in service with one country, grouped by operator type (military, law enforcement, and so on).
**Requires Builder tier or higher.**'
operationId: getCountryArsenal
x-gunspec-tier: builder
security:
- ApiKeyHeader: []
- BearerAuth: []
parameters:
- name: code
in: path
required: true
schema:
type: string
description: ISO 3166-1 alpha-2 country code
example: AU
responses:
'200':
description: Get a country arsenal
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:
countryCode:
type: string
example: AU
description: ISO 3166-1 alpha-2 code of the country, echoing the path parameter.
arsenal:
type: object
properties: {}
description: Operator type -> the firearms that type operates. Keys are dynamic; entries missing a type are grouped under "unknown".
description: The country arsenal, keyed by operator type.
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
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'