Caching and refreshing
You are encouraged to keep a local copy of the catalog. This page says how long you may keep each kind of response, how to check whether your copy is still current without downloading it, and what that check costs you.
How long you may keep it
Every lifetime below is the value the API sends in Cache-Control on that path. There is no separate policy to remember: this table is generated from the one the server stamps responses from.
| Resource | Your cache | Shared caches | Conditional requests |
|---|---|---|---|
Reference lists: cartridges, categories, manufacturers, filter options/v1/calibersVocabularies. A new manufacturer appears a few times a year, and nothing downstream breaks if you learn about it an hour late. | 1 hour | 1 day | Supported |
A random firearm/v1/firearms/randomNon-deterministic by definition. Cached even briefly, every caller would get the same "random" record for the window. | Do not store | - | Not offered |
Name resolution/v1/firearms/resolveA deterministic lookup over an index of spellings, not over the specifications themselves. The answer to "which firearm is G19" changes only when the catalogue gains a record that competes for the name, and it is the same question asked over and over - so it is the most worthwhile response on the platform to keep. | 1 hour | 1 day | Supported |
One firearm, manufacturer or cartridge/v1/firearms/ak-47Specifications change when a source is corrected, and a correction is the thing a specification database exists to propagate. Five minutes is short enough that a fix reaches readers the same day; the version field lets you hold it far longer and check cheaply. | 5 minutes | 1 hour | Supported |
Firearm lists and search/v1/firearmsA page of results depends on filters, sort and what was added today, so it goes stale faster than any single record on it. | 1 minute | 5 minutes | Supported |
Catalog summary counts/v1/stats/summaryTotals over the whole catalog. Expensive to compute, and nobody is misled by a count that is an hour behind. | 1 hour | 1 day | Supported |
Other statistics/v1/stats/top-viewedAggregates recomputed from the catalog rather than stored, so the cost of a miss is real and the value of being current is low. | 30 minutes | 1 hour | Not offered |
Site notices/v1/noticesRead on the first paint of every page. A minute is short enough that pulling a notice down is felt almost immediately, and long enough that it costs one read per location per minute rather than one per visitor. | 1 minute | 1 minute | Not offered |
Example verification/v1/sdk/verificationThe badge on every SDK page and under every cURL sample. A minute, with a short stale window: a run is published a few times a day and the page exists to show the newest one, so the write purges this key as well - an hour of stale-while-revalidate was long enough to show the previous run after a release. | 1 minute | 1 minute | Not offered |
Data quality report/v1/data/gapsRecomputed from the whole catalog on every call: a table scan per check, on a keyless endpoint that a public page reads on every visit. The figures move when the catalog is re-seeded, which is hours or days apart, so a minute costs one computation per location per minute and is never meaningfully behind. The window is deliberately short rather than an hour: the page carries a refresh control, and a button whose effect is invisible for the next fifty-nine minutes is worse than no button. | 1 minute | 1 minute | Not offered |
Data task board/v1/data/tasksThe public half of the remediation queue: that work exists on a finding and where it has got to. A minute, matching the gap report it is generated from, because the two are read together and a board a minute behind the figures beside it would look like a bug. States change when a worker claims or files something, which is minutes or hours apart, so this is never meaningfully stale. | 1 minute | 1 minute | Not offered |
Contract check results/v1/contractThe smoke suite runs daily, so five minutes is short beside the thing it describes and long enough that a health page costs one read per location. The publish purges this key, which is what makes a re-run visible immediately rather than at the end of the window. | 5 minutes | 5 minutes | Not offered |
Health checks/healthA cached health check reports the health of the cache. | Revalidate every time | - | Not offered |
Your account, billing and authentication/user/meAnswers about one person. Never stored anywhere, by us or by anything between us. | Do not store | - | Not offered |
These are the lifetimes after which you should re-check, not the lifetimes after which the data is wrong. With a conditional request you may hold a record far longer: re-check on your own schedule, and a record that has not changed costs you an empty response.
Three ways to tell whether your copy is current
Catalog records carry two of these in the body, and every response carries the third in its headers. They answer slightly different questions, so store the one that matches how you check.
updatedAt- When the record last changed. Maintained by the database itself, on a content comparison, so re-importing an identical record does not move it, so a timestamp that moved means something a reader can see is different.
version- A short hash of the fields inside the record itself. Equal versions mean equal data. Unlike an ETag it does not vary by plan or by response shape, so it is the value to store beside a mirrored record and compare across tiers.
ETag- A hash of the exact bytes of this response, for this plan. Send it back as If-None-Match. It is per-response, not per-record: two plans see the same record under different tags because they receive different fields.
Data revisions
What moves when a record is corrected, in plain words, so a mirror knows what to store and what to listen for. The API is versioned separately; versioning describes that contract.
updatedAtis when the columns we serve last changed. The database maintains it on a content comparison; nothing a writer does can stamp it without changing the data.versionis the record's content hash. Equal versions mean equal data, on every plan, so it is what a mirror stores beside a record.- A correction to a record changes both and fires
firearm.updatedto subscribers. When the sourcing or the score moved,firearm.source.changedandfirearm.confidence.changedarrive alongside it. - A catalog-wide re-stamp, meaning every record’s hash recomputed on purpose, arrives as one
catalog.resyncedrather than one event per record. Re-sync rather than applying it. - Changes to the data alone, with no change to the API, are announced in the changelog under the
datacategory. - There is no per-record revision history endpoint yet. A record carries its current state and the moment it last changed, not what it held before.
Asking without downloading
Send the ETag you were given back as If-None-Match. If the response would be byte-identical, you get 304 Not Modified with no body.
# 1. Fetch, and keep the ETag the response carried.curl -sS -D - -o firearm.json \ -H "X-API-Key: $GUNSPEC_API_KEY" \ https://api.gunspec.io/v1/firearms/ak-47 | grep -i '^etag:'# etag: "3f9c1a7d5b2e4088" # 2. Ask again with it. Unchanged records answer 304 and send no body.curl -sS -o /dev/null -w '%{http_code} %{size_download} bytes\n' \ -H "X-API-Key: $GUNSPEC_API_KEY" \ -H 'If-None-Match: "3f9c1a7d5b2e4088"' \ https://api.gunspec.io/v1/firearms/ak-47# 304 0 bytesA 304 counts toward your per-minute rate limit but not toward your daily request allowance. Checking often is deliberately cheap; hammering the API is still bounded.
Keeping a mirror in step
The loop an agent runs over records it already holds. Nothing is downloaded unless something changed.
// Refreshing a local mirror without downloading it again.async function refresh(record) { const res = await fetch(`${API}/v1/firearms/${record.id}`, { headers: { 'X-API-Key': key, // The tag you stored alongside the record last time. ...(record.etag ? { 'If-None-Match': record.etag } : {}), }, }) // Your copy is current. No body was sent and none was billed // against your daily quota. if (res.status === 304) return record const body = await res.json() return { ...body.data, etag: res.headers.get('ETag'), // `version` changes only when the data changes, so it survives // a re-serialisation that would move the ETag. version: body.data.version, updatedAt: body.data.updatedAt, }}