Skip to content

Webhooks

A watch can push matches to your endpoint instead of waiting to be polled.

POST /v1/watches/{id}/webhook

{ "url": "https://your-app.example/asic" }
{
"id": "wch_...",
"webhook_url": "https://your-app.example/asic",
"secret": "whsec_..."
}

url must be an absolute https:// URL. The secret is shown once. Store it: you need it to verify deliveries.

POST https://your-app.example/asic
X-ASIC-Signature: sha256=<hex>
X-ASIC-Delivery: dlv_...
Content-Type: application/json
{"watch_id": "wch_...", "delivered_at": "...", "notice": { ... }}

notice has the same shape as a notice object from /v1/notices.

X-ASIC-Signature is sha256= followed by an HMAC-SHA256 of the raw request body bytes, keyed with your webhook secret, in lowercase hex.

Verify against the raw body, before any JSON parsing. A framework that parses and re-serialises the body will change the bytes — key order, whitespace — and the signature will not match. Most frameworks need to be told explicitly to hand you the raw bytes.

import crypto from "node:crypto";
import express from "express";
const app = express();
// express.raw, not express.json: the signature covers the bytes on the wire.
app.post("/asic", express.raw({ type: "application/json" }), (req, res) => {
const expected =
"sha256=" +
crypto
.createHmac("sha256", process.env.ASIC_WEBHOOK_SECRET)
.update(req.body)
.digest("hex");
const sent = Buffer.from(req.get("X-ASIC-Signature") ?? "");
const want = Buffer.from(expected);
// timingSafeEqual throws on a length mismatch, so check length first.
if (sent.length !== want.length || !crypto.timingSafeEqual(sent, want)) {
return res.sendStatus(401);
}
const delivery = req.get("X-ASIC-Delivery");
if (alreadyProcessed(delivery)) return res.sendStatus(200);
const { watch_id, notice } = JSON.parse(req.body.toString("utf8"));
enqueueForProcessing(watch_id, notice, delivery);
// Acknowledge now, do the work after. See "Answer fast" below.
res.sendStatus(200);
});
import hashlib
import hmac
import os
from flask import Flask, abort, request
app = Flask(__name__)
@app.post("/asic")
def asic():
raw = request.get_data() # bytes, before any parsing
expected = "sha256=" + hmac.new(
os.environ["ASIC_WEBHOOK_SECRET"].encode(),
raw,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("X-ASIC-Signature", "")):
abort(401)
delivery = request.headers.get("X-ASIC-Delivery")
if already_processed(delivery):
return "", 200
payload = request.get_json()
enqueue_for_processing(payload["watch_id"], payload["notice"], delivery)
return "", 200

X-ASIC-Delivery is stable across retries: if an attempt times out or fails after your endpoint has actually processed it, the retry carries the same X-ASIC-Delivery value. Store it and treat a repeat as already done, so a retried delivery is not processed twice.

A delivery is abandoned if your endpoint has not responded within 10 seconds, and an abandoned attempt counts as a failure. Acknowledge with a 2xx as soon as you have verified and recorded the delivery, then do the real work on your own queue.

Return any 2xx to acknowledge. Anything else counts as a failure, including a redirect: redirects are deliberately not followed, because a redirect to a login page would otherwise look like success.

A failed delivery is retried up to 5 attempts. The wait grows with each attempt, at ten minutes per attempt so far:

After attempt Next try in
1 10 minutes
2 20 minutes
3 30 minutes
4 40 minutes
5 given up on, marked dead

So an endpoint that is down stays covered for roughly the next hour and a half before that particular notice is abandoned.

Ten consecutive failed notices deactivate the watch, which is reported as active: false with a deactivated_reason on GET /v1/watches/{id}. Reactivate with PATCH /v1/watches/{id} and a body of {"active": true}.

Reactivating a watch delivers anything that was queued up while it was off, and does so without re-checking the usual published-date window. If your endpoint was down for a while, expect a burst of notices once you turn it back on, and some of them may be older than the couple of days you’d normally see. The retry ladder exists so a recovered endpoint still gets the notices it missed, not just the ones that show up after reactivation.

What you receive is gated on when the watch was created, not when you attached the webhook: a notice is delivered only if it arrived after the watch was created and was published within the last two days. Attaching a webhook to an older watch does not reset that clock. Use GET /v1/watches/{id}/matches for full history instead.

GET /v1/watches/{id}/deliveries is the first place to look. It returns recent attempts with status, attempts and last_error — the actual error or status code your endpoint returned, which usually says what is wrong without you having to reproduce it.

{
"data": [
{
"id": "dlv_...",
"notice_id": "6ce624e8-038a-4c93-acd1-06ed9be1974d",
"notice_seq": 4821,
"status": "dead",
"attempts": 5,
"last_error": "HTTP 502",
"created_at": "2026-09-23T01:15:00.000Z"
}
]
}

status is pending, delivered or dead. Accepts ?limit= up to 100, defaulting to 50.

If the list is empty, the watch is not matching anything rather than failing to deliver — check it with GET /v1/watches/{id}/matches.

  • POST /v1/watches/{id}/webhook/rotate: new secret, same URL. The old secret stops verifying immediately, so deploy the new one promptly.
  • DELETE /v1/watches/{id}/webhook: stop delivering, keep the watch pollable.