Webhook events
Delivery
SNOW POSTs events to the URL you register per tenant. Deliveries:
- Are signed with
X-Snow-Signature: t={timestamp},v1={hmac_sha256}(note theX-prefix) - Are retried with exponential backoff on non-2xx responses
- Carry a unique
event_id— use it to deduplicate retries
Retry schedule
| Attempt | Delay after previous failure |
|---|---|
| 1 (initial) | Immediate |
| 2 | 10 s |
| 3 | 60 s |
| 4 | 5 min |
After all 4 attempts fail the event is marked dead — SNOW does not queue
events beyond ~6 minutes. No notification is currently sent when an event goes
dead; check GET /v1/me/webhook/deliveries for delivery status.
Implication: your webhook endpoint must be reliably reachable. If it goes down for more than ~6 minutes you will miss events. To recover, poll GET /v1/sessions/{id} for any sessions created during the outage.
Replay window
The t= timestamp in X-Snow-Signature is Unix seconds. Reject any delivery where |now − t| > 300 (5 minutes) to block replay attacks.
Signature verification
import crypto from "node:crypto"
function verifySnowSignature( rawBody: string, signatureHeader: string, secret: string // your whsec_… value): boolean { const [tPart, v1Part] = signatureHeader.split(",") const timestamp = tPart.replace("t=", "") const expected = v1Part.replace("v1=", "") const payload = `${timestamp}.${rawBody}` const computed = crypto .createHmac("sha256", secret) .update(payload) .digest("hex") return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(expected))}import hashlib, hmac, time
def verify_snow_signature(raw_body: bytes, signature_header: str, secret: str) -> bool: parts = dict(p.split("=", 1) for p in signature_header.split(",")) timestamp = parts["t"] expected = parts["v1"] # Replay window: reject events older than 5 minutes if abs(time.time() - int(timestamp)) > 300: return False payload = f"{timestamp}.".encode() + raw_body computed = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest() return hmac.compare_digest(computed, expected)import javax.crypto.Mac;import javax.crypto.spec.SecretKeySpec;import java.util.*;
public boolean verifySnowSignature(byte[] rawBody, String sigHeader, String secret) throws Exception { Map<String, String> parts = new HashMap<>(); for (String p : sigHeader.split(",")) { String[] kv = p.split("=", 2); parts.put(kv[0], kv[1]); } String timestamp = parts.get("t"); String expected = parts.get("v1");
// Replay window check if (Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(timestamp)) > 300) return false;
byte[] payload = (timestamp + ".").getBytes(); byte[] combined = new byte[payload.length + rawBody.length]; System.arraycopy(payload, 0, combined, 0, payload.length); System.arraycopy(rawBody, 0, combined, payload.length, rawBody.length);
Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256")); String computed = bytesToHex(mac.doFinal(combined)); return MessageDigest.isEqual(computed.getBytes(), expected.getBytes());}Always reject events where the signature doesn’t match.
Events
session.created
Fired immediately when the session is created.
{ "event": "session.created", "event_id": "evt_01hwxyz", "session_id": "ses_01hwxyz", "type": "pre-consult", "metadata": {}}session.processing
Fired when the worker picks up the job (patient submitted the form).
{ "event": "session.processing", "event_id": "evt_01hwxyz", "session_id": "ses_01hwxyz"}session.completed
Fired when the summary is ready. Fetch the result from GET /v1/sessions/{id}.
{ "event": "session.completed", "event_id": "evt_01hwxyz", "session_id": "ses_01hwxyz", "type": "pre-consult"}session.failed
Fired when processing fails.
{ "event": "session.failed", "event_id": "evt_01hwxyz", "session_id": "ses_01hwxyz", "error_code": "processing_error", "retriable": false}Idempotency
Your handler must be idempotent. Store event_id on receipt and skip processing if you’ve seen it before — SNOW retries on timeouts and 5xx responses.
app.post("/snow-webhook", express.raw({ type: "application/json" }), async (req, res) => { const sig = req.headers["snow-signature"] as string if (!verifySnowSignature(req.body.toString(), sig, process.env.SNOW_WEBHOOK_SECRET!)) { return res.status(401).send("Invalid signature") } const event = JSON.parse(req.body.toString())
if (await db.events.exists(event.event_id)) return res.sendStatus(200) await db.events.insert(event.event_id)
if (event.event === "session.completed") { const session = await snowClient.sessions.get(event.session_id) await db.summaries.upsert({ appointmentId: session.metadata.appointment_id, result: session.result }) }
res.sendStatus(200)})