Skip to content

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 the X- prefix)
  • Are retried with exponential backoff on non-2xx responses
  • Carry a unique event_id — use it to deduplicate retries

Retry schedule

AttemptDelay after previous failure
1 (initial)Immediate
210 s
360 s
45 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))
}

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