API Reference
Webhooks
We POST each event to your HTTPS endpoint the moment it happens, signed, and keep retrying for about a day if you are down.
How delivery works
Add an endpoint in Settings → Webhooks, or with Create a webhook,
and pick the events it should hear. When one happens we send a POST with a JSON
body to your URL.
Answer with any 2xx within 10 seconds and the delivery is done. Anything else —
another status, a timeout, a refused connection — counts as a failure and is
retried.
Answer first, work later. Put the event on a queue and return 200 straight
away; a slow handler reads to us as a failed one.
Events
| Event | When it fires | data |
|---|---|---|
message.received | A text arrives on one of your numbers. | A message |
message.delivered | The carrier confirms a text you sent arrived. | A message |
message.failed | The carrier gives up on a text you sent. | A message |
call.ringing | A call starts ringing, in or out. | A call |
call.answered | Someone picks up. | A call |
call.completed | A call ends, answered or not. | A call |
call.missed | An inbound call ends without being answered. Sent alongside call.completed. | A call |
call.recording.completed | A call's recording is ready to fetch. | A call with recording |
call.voicemail.completed | A caller left a voicemail; the recording is the voicemail. | A call with recording |
call.transcript.completed | A call's transcript is ready. | A call with transcript |
call.summary.completed | A call's AI summary is ready. | A call with summary |
contact.created | A contact is added to the workspace. | A contact |
contact.updated | A workspace contact changes. | A contact |
contact.deleted | A workspace contact is removed. | The contact as it was |
Calls between teammates are internal and never fire. Contacts fire only for contacts shared with the workspace — the ones List contacts returns.
The payload
Every event arrives in the same envelope. data is the same object the matching
GET endpoint returns, so you parse one shape whichever way you read it.
{
"id": "0f6e2d1c-9a8b-4e7f-b3c2-1d0e9f8a7b6c",
"type": "message.received",
"apiVersion": "2026-09-25",
"createdAt": "2026-09-25T14:02:11Z",
"data": {
"id": "8c1d4e7a-5b2f-4a90-9e1c-3f7d2b6a1c08",
"createdAt": "2026-09-25T14:02:11Z",
"direction": "inbound",
"from": "+447700900123",
"media": [
{
"contentType": "image/jpeg",
"url": "https://files.ringhouse.ai/media/8c1d4e7a/photo.jpg"
}
],
"numberId": "e41b09c2-77d3-4c5e-b0a8-9d2f61c4e3a7",
"status": "received",
"text": "Is my table ready for eight?",
"to": "+441134960000"
}
}id— the event's id. An event sent to two of your endpoints has the same id at both.type— one of the events above.apiVersion— the payload's version. Fields may be added within a version; none are removed or renamed.createdAt— when the event happened, not when it was sent.
A call event carries the call, plus whatever that event is about:
{
"id": "0f6e2d1c-9a8b-4e7f-b3c2-1d0e9f8a7b6c",
"type": "call.recording.completed",
"apiVersion": "2026-09-25",
"createdAt": "2026-09-25T14:02:11Z",
"data": {
"id": "8c1d4e7a-5b2f-4a90-9e1c-3f7d2b6a1c08",
"createdAt": "2026-09-25T14:02:11Z",
"direction": "inbound",
"duration": 184,
"endedAt": "2026-09-25T14:05:15Z",
"from": "+447700900123",
"hasRecording": true,
"hasSummary": false,
"hasTranscript": false,
"isVoicemail": false,
"missed": false,
"numberId": "e41b09c2-77d3-4c5e-b0a8-9d2f61c4e3a7",
"startedAt": "2026-09-25T14:02:11Z",
"status": "completed",
"to": "+441134960000",
"recording": {
"duration": 184,
"url": "https://files.ringhouse.ai/recordings/8c1d4e7a/recording.wav"
}
}
}Verifying signatures
Each delivery is signed with your endpoint's secret, following the Standard Webhooks spec, so any of its libraries will verify it as-is. Three headers come with every request:
| Header | What it holds |
|---|---|
webhook-id | The delivery's id. The same on every retry of it. |
webhook-timestamp | When this attempt was sent, in Unix seconds. |
webhook-signature | v1, followed by the base64 HMAC-SHA256 signature. |
To check one, take the secret after its whsec_ prefix and base64-decode it. Sign
{webhook-id}.{webhook-timestamp}.{body} with HMAC-SHA256, base64 the result, and
compare it with the signature in constant time. Reject timestamps more than five
minutes from your clock, so a captured request cannot be replayed later.
import crypto from "node:crypto";
// `body` is the raw request body as a string, before any JSON parsing.
export function verify(secret, headers, body) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = crypto
.createHmac("sha256", key)
.update(`${id}.${timestamp}.${body}`)
.digest();
return headers["webhook-signature"].split(" ").some((entry) => {
const given = Buffer.from(entry.split(",")[1] ?? "", "base64");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}Verify the raw body exactly as it arrived. Parsing the JSON and serialising it again changes the bytes, and the signature will never match.
Find the secret on the webhook's page in settings. Rotating it takes effect on the next delivery.
Retries
A failed delivery is tried again on this schedule, about 27 hours in all:
| Attempt | Waits after the one before |
|---|---|
| 2 | 5 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 5 hours |
| 7 | 10 hours |
| 8 | 10 hours |
If the eighth attempt fails, the webhook is stopped: nothing more is sent to it,
and the workspace's admins get an email saying so. Turn it back on in settings, or
with Update a webhook and enabled: true. Deliveries missed
while it was stopped are not replayed, but you can retry any one
from its log.
Duplicates and order
Delivery is at least once. A retry after a timeout can reach you twice if your
server did the work but answered too late. Keep the webhook-id of what you have
handled and skip one you have seen.
Events are sent in the order they happened, but a retry can land after a newer
event. Order by createdAt, or fetch the resource when you need its latest state.
Filtering by number
Give an endpoint numberIds and it only hears events on those numbers; leave it
out to hear every number, including ones added later. Contact events belong to
the workspace rather than a number, so every endpoint subscribed to them hears
them.
Testing an endpoint
Send test on the webhook's page, or Test a webhook, sends a
signed webhook.test event within a few seconds. It is never sent otherwise, so it
is safe to ignore in production. Every delivery, with the status and time of its
last attempt, is in the webhook's log.