Set up a webhook step by step
Create a webhook, check its signature and understand what happens when delivery fails.
Updated Oct 25, 2026 · 4 min read
On this page
Create a webhook #
- Open Integrations, then Webhooks, then New.
- Enter your HTTPS URL.
- Pick the events you want. Most people start with Transcript Ready, the complete call record.
- Optional: narrow it by direction (inbound or outbound), channel (phone or web) and agent. Calls from the website widget are sent like phone calls, with
channelset toweb. Test calls you make from the agent page (Test, then Call your agent) are left out unless you turn on Also send test calls; their events carry"is_test": true. - Optional: add custom request headers, for a receiver that needs its own token, such as
Authorization: Bearer <their token>. - Optional: pick a body format. See Send calls to HubSpot, Slack, Zapier or your CRM.
- Save.
The signing secret is shown once, so copy it. A test event is sent automatically when you save.
Send a test #
Use Send Test to send an example of any event type with realistic fields. Test events include "test": true.
What you receive #
Each request is a JSON POST with id, type, api_version, created_at and data.object. The full list of events and headers is in Webhooks and API basics.
transcript.ready is the complete call record (caller details, collected data, full transcript and summary). call.completed arrives first with summary: null.
Verify the signature #
The X-Webhook-Signature header looks like t=<unix timestamp>,v1=<hex hmac>.
- Build the string
<t>.<raw request body>. Use the raw body, not re-encoded JSON. - Compute HMAC-SHA256 of it with your signing secret.
- Compare with
v1in constant time. - Reject the request if
tis older than 5 minutes.
For 24 hours after Rotate Secret (when the overlap option is left on), the header carries a second v1= signed with the old secret. Accept the request if any v1 matches.
Node.js:
const crypto = require('crypto');
function verify(rawBody, header, secret) {
const parts = header.split(',').map((p) => p.split('='));
const t = parts.find(([k]) => k === 't')[1];
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return parts.filter(([k]) => k === 'v1').some(([, v]) =>
v.length === expected.length && crypto.timingSafeEqual(Buffer.from(v), Buffer.from(expected)));
}
PHP:
function verify(string $rawBody, string $header, string $secret): bool
{
$t = null; $sigs = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = array_pad(explode('=', $part, 2), 2, '');
if ($k === 't') $t = $v;
if ($k === 'v1') $sigs[] = $v;
}
if (! $t || abs(time() - (int) $t) > 300) return false;
$expected = hash_hmac('sha256', $t.'.'.$rawBody, $secret);
foreach ($sigs as $sig) {
if (hash_equals($expected, $sig)) return true;
}
return false;
}
Delivery rules #
- HTTPS only, public addresses only.
- Redirects are not followed.
- Respond with a 2xx status within 10 seconds.
Retries and failures #
- We try up to 5 times, after 10 seconds, 60 seconds, 5 minutes, 30 minutes and 2 hours.
429or503with aRetry-Afterheader is respected.400,413and422are not retried, because your server rejected the body.410switches the endpoint off immediately.- 5 failed deliveries in a row switch the endpoint off, and the account owner gets an email. Switch Active back on to resume.
A delivery that is still pending with 0 attempts after 5 minutes has not left our side yet. We queue it again automatically every few minutes during its first hour. After that, or to try at once, use Resend on that row.
Events are not replayed automatically after an outage on your side. Use Resend in the Delivery Log, which keeps 30 days of history.
To see exactly what was sent, open Request on any row of the Delivery Log. It shows the address, every header and the body. The values of headers you added yourself are hidden.
The same event keeps the same id on every retry, so de-duplicate on X-Webhook-ID. Order is not guaranteed.