Receive post-call webhooks
Get a signed event with the transcript, summary, analysis and recording links each time one of an agent's calls finishes.
Each agent has one webhook. It covers all of that agent's inbound, outbound and test calls that finish after you turn it on. The routes are in the post-call webhooks reference.
- Set up an endpoint
Your endpoint must accept
POSTrequests and meet these rules:- It uses HTTPS on port 443, with no username, password or fragment in the URL.
- Its hostname resolves only to public internet addresses.
- It returns a
2xxstatus within 10 seconds. Redirects aren't followed.
Use the verification code below as a starting point.
- Save the webhook
Send the URL with
PUT /v1/agents/:id/post-call-webhook, using a key withwritepermission. On the first save, setexpectedVersiontonull.curl -X PUT "https://backend.synthesys.app/api/v1/agents/3f0c1a52-7b7e-4c7a-9a55-1f2d3e4c5b6a/post-call-webhook" \ -H "SYNTH_API_KEY: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/webhooks/calls", "enabled": true, "expectedVersion": null }'Keep
config.versionfrom the response. You need it to send a test and to save further changes.{ "success": true, "data": { "deliveryAvailable": true, "config": { "url": "https://example.com/webhooks/calls", "enabled": true, "version": "a95d3c18-7e42-4f60-8b1d-0c2e4f6a8b90", "hasSigningSecret": true, "updatedAt": "2026-10-31T14:00:00+00:00" }, "deliveries": [] } } - Copy the signing secret
The first save creates a signing secret for this agent. The API never returns it.
In the dashboard, open the agent and go to the Webhook logs tab. Select Reveal signing secret. Only workspace admins can do this.
The secret starts with
whsec_, and you use the whole string, prefix included. It can't be rotated, and it stays the same when you change the URL. - Send a test
POST /v1/agents/:id/post-call-webhook/testqueues awebhook.testevent with synthetic data. No call is placed. Tests also send while the webhook is turned off.curl -X POST "https://backend.synthesys.app/api/v1/agents/3f0c1a52-7b7e-4c7a-9a55-1f2d3e4c5b6a/post-call-webhook/test" \ -H "SYNTH_API_KEY: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "expectedVersion": "a95d3c18-7e42-4f60-8b1d-0c2e4f6a8b90" }'{ "success": true, "data": { "deliveryId": "2f8b6e10-4d7a-4c39-a1e5-7b9c0d1e2f3a" } }Each webhook version sends one test per minute. A second request in the same minute returns the same
deliveryIdand sends nothing new.Then read the delivery log to see the result.
Read the event
Every request has these headers:
| Header | Value |
|---|---|
X-Synthesys-Event-Id | The event's UUID. It stays the same across retries and matches id in the body. |
X-Synthesys-Timestamp | Unix time in seconds when this attempt was signed. |
X-Synthesys-Signature | v1= followed by the hex signature. |
Content-Type | application/json |
User-Agent | Synthesys-Webhook/1.0 |
The body looks like this:
{
"id": "2f8b6e10-4d7a-4c39-a1e5-7b9c0d1e2f3a",
"type": "call.completed",
"version": 1,
"createdAt": "2026-10-31T14:04:34+00:00",
"test": false,
"call": {
"id": "5b9e2d70-1c34-4a8f-9e62-7d1a0c3f4b25",
"agentId": "3f0c1a52-7b7e-4c7a-9a55-1f2d3e4c5b6a",
"status": "ended",
"direction": "outbound",
"type": "outboundPhoneCall",
"isTestCall": false,
"customerPhone": "+12025550123",
"startedAt": "2026-10-31T14:02:11+00:00",
"endedAt": "2026-10-31T14:04:33+00:00",
"durationSeconds": 142,
"endedReason": "customer-ended-call",
"transcript": "AI: Hi Jane, I am calling about your growth plan.\nUser: Thanks. I would like to upgrade next month.",
"summary": "Jane asked to upgrade her plan next month.",
"analysis": null,
"recordings": [
{
"id": "e41f7c09-6a2b-4d53-b8c1-90d2f3a4e5b6",
"url": "https://example.com/signed-recording-link",
"expiresAt": "2026-10-31T15:04:35.000Z"
}
]
}
}Event fields
idstringtypestringcall.completed, call.updated, call.recording_ready or webhook.test.versioninteger1.createdAtstringtestbooleantrue only for webhook.test events.call.idstring:id in GET /v1/call-logs/:id. It isn't the callId from POST /v1/calls. In a webhook.test event it's a random ID that matches no call.call.agentIdstringcall.statusstringended or failed.call.directionstringinbound or outbound, or null.call.typestringcall.isTestCallbooleannull.call.directCallIdstringcallId from POST /v1/calls, or null for calls placed another way.call.customerPhonestringnull.call.startedAtstringnull.call.endedAtstringnull.call.durationSecondsintegernull.call.endedReasonstringnull. See Mapped values.call.transcriptstringnull.call.summarystringnull.call.analysisobjectnull. Don't rely on a fixed shape.call.recordingsarrayid, url and expiresAt.Mapped values
call.type and call.endedReason never name a specific voice or phone company. Any part of a value that would do so uses the word provider instead, such as provider-failed-to-connect-call. Other values, such as customer-ended-call, are unchanged. Call logs and recordings use the same values.
To connect an event to a call you placed, read the log with GET /v1/call-logs/:id. Its directCallId is your callId.
Event types
| Event | Sent when |
|---|---|
call.completed | The call first finishes. This fires once per call and webhook version, including for failed calls. |
call.updated | A later change adds or corrects call details. |
call.recording_ready | A recording becomes available after call.completed has gone out. |
webhook.test | You request a test. |
call.completed waits up to 60 seconds for the transcript and end time. If they still haven't arrived, it's sent without them, and a call.updated event follows. A recording that's ready before the first send is included in call.completed.
Events can arrive out of order. Each one holds the full call as of that moment, so update your own record by call.id.
Recording links
Each url is a signed link that lasts one hour, and expiresAt gives the time. Download the file while handling the event. If the recording is no longer available at send time, url and expiresAt are null. You can get a new link at any time with GET /v1/recordings/:id/download-url.
Verify the signature
Check every request before you trust it:
- Read the raw request body before any JSON parsing. Re-encoded JSON won't match.
- Compute an HMAC-SHA256 of
timestamp + "." + rawBody, using your signing secret as the key. - Hex-encode the result and put
v1=in front of it. - Compare that with
X-Synthesys-Signatureusing a constant-time comparison. - Reject old timestamps. These samples allow five minutes, which is safe because every retry is signed with a new timestamp.
These servers use only the standard library. Save the JavaScript as a .mjs file. Replace YOUR_SIGNING_SECRET, and run them behind an HTTPS proxy on port 443.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
const SIGNING_SECRET = 'YOUR_SIGNING_SECRET';
const MAX_AGE_SECONDS = 300;
const seenEventIds = new Set(); // Use a database in production.
function isValidSignature(rawBody, timestamp, signature) {
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!(age <= MAX_AGE_SECONDS)) return false;
const digest = createHmac('sha256', SIGNING_SECRET)
.update(`${timestamp}.`)
.update(rawBody)
.digest('hex');
const expected = Buffer.from(`v1=${digest}`);
const received = Buffer.from(signature ?? '');
return expected.length === received.length && timingSafeEqual(expected, received);
}
const server = createServer(async (req, res) => {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const rawBody = Buffer.concat(chunks);
const valid = isValidSignature(
rawBody,
req.headers['x-synthesys-timestamp'],
req.headers['x-synthesys-signature'],
);
if (!valid) {
res.writeHead(401).end();
return;
}
const eventId = req.headers['x-synthesys-event-id'];
if (!seenEventIds.has(eventId)) {
seenEventIds.add(eventId);
const event = JSON.parse(rawBody.toString('utf8'));
console.log(event.type, event.call.id, event.call.status);
}
res.writeHead(200).end();
});
server.listen(3000);Handle retries and duplicates
Return a 2xx as soon as you've stored the event, and do slow work afterwards. Synthesys makes up to five attempts in total for each event.
| Your endpoint | What happens |
|---|---|
Returns 2xx | Delivered. |
Returns 429 or 5xx | Retried. |
| Times out after 10 seconds or can't be reached | Retried. |
Returns any other status, including 3xx and other 4xx | Failed, with no retry. |
| Resolves to a non-public address | Failed, with no retry. |
The first retry comes about 90 seconds later, and later ones wait up to about 15 minutes.
Handle these cases in your code:
- The same event can arrive more than once. Skip any
X-Synthesys-Event-Idyou've already processed. - The body can change between attempts. Each attempt has fresh recording links, so deduplicate on the event ID and not on a hash of the body.
- A failed event isn't resent. No route redelivers it, so read the call from
GET /v1/call-logs/:idwhen you need to catch up.
Read the delivery log
GET /v1/agents/:id/post-call-webhook/deliveries returns the agent's 10 most recent events. Delivery records are kept for 30 days. This route needs write permission, and a read key gets 403.
curl "https://backend.synthesys.app/api/v1/agents/3f0c1a52-7b7e-4c7a-9a55-1f2d3e4c5b6a/post-call-webhook/deliveries" \
-H "SYNTH_API_KEY: YOUR_API_KEY"{
"success": true,
"data": {
"deliveryAvailable": true,
"deliveries": [
{
"id": "2f8b6e10-4d7a-4c39-a1e5-7b9c0d1e2f3a",
"call_id": null,
"event_type": "webhook.test",
"status": "delivered",
"attempts": 1,
"last_http_status": 200,
"last_error": null,
"created_at": "2026-10-31T14:01:00+00:00",
"delivered_at": "2026-10-31T14:01:01+00:00"
}
]
}
}Delivery fields
idstringX-Synthesys-Event-Id.call_idstringnull for a test.event_typestringstatusstringpending, delivered, failed or canceled. An event waiting for a retry is pending.attemptsintegerlast_http_statusintegernull.last_errorstringnull. For example, Endpoint returned HTTP 500.created_atstringdelivered_atstringnull.Change the webhook
Send the current config.version as expectedVersion. If it's out of date, the request returns 409. Read the settings again with GET /v1/agents/:id/post-call-webhook, then retry with the new version.
Changing url or enabled creates a new version, with these effects:
- Events still waiting to send are canceled. They aren't sent to the new address.
- Only calls that end after the change produce events. This also applies when you turn a webhook back on.
To stop events, send enabled: false with your current URL.
Errors
| Status | Route | Cause |
|---|---|---|
400 | All | The agent ID isn't a UUID. |
400 | Save, test | The body is invalid, the URL breaks the endpoint rules, or the hostname can't be resolved. Save and test also return 400 when deliveryAvailable is false. |
403 | All | The API key doesn't have write permission. A read key can't read webhook settings or deliveries. |
404 | All | The agent doesn't exist in your account. |
409 | Save, test | expectedVersion doesn't match the current version. A test also returns 409 if no webhook has been saved. |