GunSpec
Webhooks

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.

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.

EventWhen 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.createdA firearm was added to the catalog.
firearm.updatedA firearm changed: one of the fields we serve moved.
firearm.deletedA firearm was removed. The payload is the id alone.
firearm.variant.updatedA 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.changedWhere a figure came from moved, either the sources list or a ballistics reference. Arrives alongside firearm.updated.
firearm.confidence.changedThe 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.createdA manufacturer was added to the catalog.
manufacturer.updatedA manufacturer changed: one of the fields we serve moved.
manufacturer.deletedA manufacturer was removed. The payload is the id alone.
caliber.createdA cartridge was added to the catalog.
caliber.updatedA cartridge changed: one of the fields we serve moved.
caliber.deletedA cartridge was removed. The payload is the id alone.
category.createdA category was added to the catalog.
category.updatedA category changed: one of the fields we serve moved.
category.deletedA 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.createdAn image was added to the catalog.
image.deletedAn 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.resyncedToo 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.

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
bash
# 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_.../test

The 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.

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.

http
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.

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.

typescript
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)}

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.

javascript
// 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.

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.