Webhooks
Events, signatures, retries, and the 24-hour window.
Vene posts an event to your webhook URL whenever something needs your attention. Each event carries the whole action, so you never need to call back to read it.
Vene sets one webhook URL for your organization and gives you its signing secret once. Production posts only to HTTPS URLs.
Events
| Type | Sent when | data |
|---|---|---|
action.question | One or more questions opened. The open ones have answer: null. | { "action": Action } |
action.call_started | A call for the action began. Optional to listen to. | { "action": Action, "call": Call } |
action.call_ended | A call for the action ended, with its result. Optional to listen to. | { "action": Action, "call": Call } |
action.ended | The action ended as succeeded, failed, or cancelled. | { "action": Action } |
inbound.message | A place called about one of your patients with no open action, and left a message. | { "message": InboundMessage } |
You need only action.question, action.ended, and inbound.message. The
call events show progress; answer them with 2xx even when you ignore them.
The action's full history is at
List events. Besides these, it holds
action.callback_received and action.answered, which never reach your
webhook. In that list every event carries the action as it is now; a webhook
carries it as it was when the event happened.
Each event has an id (evt_...), its type, created_at, and data. The
event reference lists every field:
Action question,
Action call started,
Action call ended,
Action ended, and
Inbound message.
Example
{
"id": "<event_id>",
"type": "inbound.message",
"created_at": "2026-11-04T16:22:05.000Z",
"data": {
"message": {
"id": "<message_id>",
"patient_ref": "pt_123",
"call_id": "<call_id>",
"caller_name": "Rosa at Oakland Heart Center",
"phone": "+15105550147",
"message": "Dr. Chen needs a recent lipid panel before the visit on the 10th. Please have the patient get labs this week.",
"received_at": "2026-11-04T16:22:05.000Z"
}
}
}Verifying the signature
Every request carries a Vene-Signature header:
Vene-Signature: t=<timestamp>,v1=<signature>t is the time Vene signed the request, in Unix seconds. v1 is the hex
HMAC-SHA256 of "<t>.<raw body>", keyed with your signing secret. To verify:
- Read the raw request body before any JSON parsing.
- Split the header on
,, then each part on its first=. - Refuse the request when
tis more than five minutes from your clock. - Compute the HMAC of
t, a., and the raw body, and compare it tov1in constant time.
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verifyVeneSignature(rawBody: string, header: string | undefined, secret: string) {
if (!header) return false
const parts = new Map(
header.split(',').map((part) => {
const separator = part.indexOf('=')
return [part.slice(0, separator).trim(), part.slice(separator + 1).trim()] as const
}),
)
const timestamp = Number(parts.get('t'))
const received = parts.get('v1')
if (!Number.isSafeInteger(timestamp) || !received) return false
if (Math.abs(Date.now() / 1000 - timestamp) > 5 * 60) return false
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex')
const a = Buffer.from(received)
const b = Buffer.from(expected)
return a.length === b.length && timingSafeEqual(a, b)
}Delivery and retries
- Answer with any
2xxstatus within 10 seconds. Do slow work after you answer. - Vene does not follow redirects. A
3xxcounts as a failure. - A failed delivery is retried 30 seconds later, then after a delay that doubles each time, up to one hour between attempts.
- Vene stops retrying 24 hours after the event. An undelivered event then expires. Its action is still readable through the API.
- Each event is sent once per endpoint after it succeeds. A slow
2xxthat times out on Vene's side counts as a failure and is retried, so an event can still arrive twice.
Handling events
Events can arrive more than once, and out of order.
- Deduplicate by
event.id. The same change always carries the same ID, in webhooks and in the events list. - Each webhook carries the action as it was when the event happened. Keep the
copy with the newest
data.action.updated_at. - Treat a webhook as a prompt to act. When in doubt, retrieve the action.