Guides
Webhooks
"notify": {
"channels": ["webhook"],
"repeat": "on_change",
"cooldown": "6h",
"webhook": { "url": "https://you.example/snag", "events": ["matched", "changed", "blocked"] }
}Events
type | When | data |
|---|---|---|
matched | The conditions hold for the first time | results, conditions, and then if a follow-up was created |
changed | The matched result changed | previous, current, what |
field_found | A release date, price or opening time appeared | site, url, field, value |
blocked | A site blocked 3 runs in a row, or works again | site, since, runs, recovered |
clamped | We slowed your schedule to a site's minimum | clamps |
Every event has the same envelope:
{
"id": "evt_4m2k9q8x7c1v0b3n5z6w",
"type": "changed",
"created_at": "2026-11-05T08:00:12.004Z",
"watcher_id": "wch_dhjqcvaqnhrchsnv7gtc",
"kind": "availability",
"data": {
"group": "site:allbirds.com",
"previous": [{ "site": "allbirds.com", "attributes": { "size": 10 }, "price": { "cents": 11000, "currency": "USD" }, "availability": "low_stock" }],
"current": [{ "site": "allbirds.com", "attributes": { "size": 10 }, "price": { "cents": 8800, "currency": "USD" }, "availability": "in_stock" }],
"what": ["price", "availability"]
}
}Each result has site, url, title, version, attributes, price, availability, quantity, date, release_status, release_date and seller. Anything the page didn't show is null.
Repeat and cooldown
repeat | Sends |
|---|---|
once | The first match only |
on_change | The first match, then each change. A price has to move 1% or more. The default |
every_match | Every run that matches |
cooldown is the shortest gap between sends; the default is 6h. Restocks and new slots ignore it.
Verify the signature
Snag-Signature: t=1793820000,v1=5f1c0e...
Snag-Event-Id: evt_4m2k9q8x7c1v0b3n5z6wv1 is the hex HMAC-SHA256 of t, a dot and the raw request body, keyed with your webhook_secret. Reject a t more than 5 minutes old, and use Snag-Event-Id to drop repeats.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifySnag(secret, rawBody, header) {
const { t, v1 } = Object.fromEntries(header.split(',').map((part) => part.split('=')));
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = createHmac('sha256', secret).update(<span class="hljs-subst">${t}</span>.<span class="hljs-subst">${rawBody}</span>).digest('hex');
return v1.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}
import hmac, hashlib, time
def verify_snag(secret: str, raw_body: bytes, header: str) -> bool:
parts = dict(part.split("=", 1) for part in header.split(","))
if abs(time.time() - int(parts["t"])) > 300:
return False
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
Verify against the raw body, before your framework parses the JSON.
Retries
Answer with a 2xx within 10 seconds. Anything else is retried after 1m, 5m, 30m, 2h and 12h, then dropped. After 20 dropped deliveries in a row, we stop sending for that watcher.
The URL must use https (or localhost with a test key) and can't resolve to a private address.