Webhooks
The other way round: instead of asking us whether a record changed, have us tell you. A mirror kept this way needs no polling loop at all, because the delivery carries the record itself.
What you can subscribe to
Every event below is accepted by the API today. This list is the same one that validates your subscription request, so it cannot offer something you would be refused. A change means the fields we serve actually moved, decided by the same comparison that sets updatedAt, so your webhook and your conditional request can never disagree.
| Event | When it fires |
|---|---|
| Firearmsfirearm.variant.updated repeats the payload for records that have a parent, so you can follow one family without taking the whole catalog's traffic. The source and confidence events tell you that a figure's provenance moved rather than the figure itself, and arrive alongside firearm.updated. | |
firearm.created | A firearm was added to the catalog. |
firearm.updated | A firearm changed: one of the fields we serve moved. |
firearm.deleted | A firearm was removed. The payload is the id alone. |
firearm.variant.updated | A firearm that has a parent was created or changed. The payload is identical to firearm.updated, sent again so a family can be followed on its own. |
firearm.source.changed | Where a figure came from moved, either the sources list or a ballistics reference. Arrives alongside firearm.updated. |
firearm.confidence.changed | The record's data confidence moved. Arrives alongside firearm.updated. |
| Reference dataManufacturers, cartridges and categories. Smaller and slower-moving than the firearm catalog, and worth subscribing to separately if you mirror them as their own tables. | |
manufacturer.created | A manufacturer was added to the catalog. |
manufacturer.updated | A manufacturer changed: one of the fields we serve moved. |
manufacturer.deleted | A manufacturer was removed. The payload is the id alone. |
caliber.created | A cartridge was added to the catalog. |
caliber.updated | A cartridge changed: one of the fields we serve moved. |
caliber.deleted | A cartridge was removed. The payload is the id alone. |
category.created | A category was added to the catalog. |
category.updated | A category changed: one of the fields we serve moved. |
category.deleted | A category was removed. The payload is the id alone. |
| ImagesRaised when a firearm's imagery is added or removed. A firearm gaining a render is also a change to the record, so firearm.updated fires as well. | |
image.created | An image was added to the catalog. |
image.deleted | An image was removed. The payload is the id alone. |
| Whole-catalogOne event standing in for a change too large to describe record by record. | |
catalog.resynced | Too many records changed at once to describe one by one. Re-sync from the API rather than applying it. |
catalog.resynced arrives when more than 1,000 records change at once, which means the catalog was re-derived rather than corrected. Re-sync from the API when you see it; do not try to apply it as a per-record change. It reaches every endpoint subscribed to any event, whether or not you asked for this one, because the alternative is silence on the one day your whole copy went stale.
Registering an endpoint
Your URL must be reachable from the public internet and answer quickly. How many endpoints you may hold depends on your plan:
- studio5 endpoint
- enterprise20 endpoint
# The signing secret is in this response and nowhere else.# Store it before you close the terminal.curl -sS -X POST \ -H "X-API-Key: $GUNSPEC_API_KEY" \ -H 'Content-Type: application/json' \ -d '{ "url": "https://your-app.example.com/hooks/gunspec", "description": "catalog mirror", "events": ["firearm.created", "firearm.updated", "firearm.deleted"] }' \ https://api.gunspec.io/v1/me/webhooks # Send yourself a test delivery to prove the receiver before relying on it.curl -sS -X POST \ -H "X-API-Key: $GUNSPEC_API_KEY" \ https://api.gunspec.io/v1/me/webhooks/wh_.../testThe signing secret is returned by the create call and never again. No endpoint will show it to you later, so if you lose it, delete the endpoint and register a new one.
What a delivery looks like
A POST carrying one event. The data is the record in the same shape the detail endpoint returns it for your plan, version included, so it can go straight into your own store without a second call. A deletion carries the id alone. It is the one change a poller cannot see, because a record it holds simply stops appearing, which is indistinguishable from a filter it got wrong.
POST /hooks/gunspec HTTP/1.1Content-Type: application/jsonX-Webhook-Id: evt_2f418305-4d11-4d2f-8075-994326c31276X-Webhook-Event: firearm.updatedX-Webhook-Delivery: b4c2bd96-f3a3-48e9-b6a4-b4c05489b632X-Webhook-Signature: t=1789012345,v1=9f86d081884c7d65... { "id": "evt_2f418305-4d11-4d2f-8075-994326c31276", "type": "firearm.updated", "created_at": "2026-09-10T08:34:48.000Z", "data": { "id": "glock-g48-mos", "name": "Glock G48 MOS", "parentFirearmId": "glock-g48", "barrelLengthMm": 106, "version": "8c1f3a90d24b", "updatedAt": "2026-09-10 08:34:48" }}X-Webhook-Id- Identifies the event. Unchanged across retries and identical for every endpoint that receives the same change, so this is the value to dedupe on.
X-Webhook-Event- The event type, so a receiver can route without parsing the body first.
X-Webhook-Delivery- This individual attempt, different on every retry. Quote it when asking us why something never arrived, but do not deduplicate on it.
X-Webhook-Signature- t=<unix seconds>,v1=<hex>. The hex is HMAC-SHA256 over the timestamp, a full stop, and the raw request body, keyed with your signing secret.
Verifying a delivery
Check the signature before you trust the body. Two things are easy to get wrong: sign the raw bytes you received rather than a re-serialised object, because JSON.stringify may reorder keys or change spacing; and compare in constant time so the comparison itself does not leak the expected value.
import { createHmac, timingSafeEqual } from 'node:crypto' // Verify against the RAW body. A re-serialised object will not match:// JSON.stringify is free to reorder keys and change spacing.export function verify(rawBody: string, header: string, secret: string): boolean { const parts = new Map(header.split(',').map((p) => p.split('=') as [string, string])) const timestamp = parts.get('t') const signature = parts.get('v1') if (!timestamp || !signature) return false // Reject anything older than five minutes so a captured delivery // cannot be replayed at you later. if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false const expected = createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`) .digest('hex') const a = Buffer.from(expected, 'hex') const b = Buffer.from(signature, 'hex') return a.length === b.length && timingSafeEqual(a, b)}Writing the receiver
Answer 2xx as soon as the signature checks out and do the work afterwards. A slow receiver is indistinguishable from a broken one and both get retried, so a handler that imports the record before replying will be sent it again while it is still working.
// Acknowledge first, process afterwards. A slow receiver is// indistinguishable from a broken one, and both get retried.app.post('/hooks/gunspec', async (req, res) => { const raw = await readRawBody(req) if (!verify(raw, req.headers['x-webhook-signature'], SECRET)) { return res.status(401).end() } const event = JSON.parse(raw) // Dedupe on the event id: a retry re-sends the same one, so this is // what makes handling idempotent. The delivery id differs per attempt. if (await seen(event.id)) return res.status(200).end() await remember(event.id) res.status(200).end() await enqueue(event)})A non-2xx or a timeout is retried 3 times, 1, 5 and 15 minutes after the failure, and then given up on. Because a retry carries the same X-Webhook-Id, handling it twice is safe if you dedupe on that.
Still keep the conditional refresh
A webhook is a change signal, not a delivery guarantee: an endpoint can be down for longer than the retry window, and a delivery that exhausts its retries is not sent again. Keep the If-None-Match refresh loop on the Caching page as the backstop and let webhooks decide when to run it. You then re-check on a schedule you control and pay nothing for the records that have not changed.