Post-call webhooks
Configure an agent to send signed call results to your HTTPS endpoint, and check delivery history.
Each agent can send signed JSON events to one HTTPS address when its calls end. Events carry the transcript, summary, analysis and recording links. For a step-by-step setup, see Receive post-call webhooks.
Every route here needs a key with write permission, including the read routes. A key with only read permission gets 403 FORBIDDEN, because saved addresses can hold tokens and aren't masked.
A missing or invalid key returns 401. :id must be the agent's UUID, or the route returns 400. An agent in another account returns 404.
A body or :id that fails validation returns 400 with the code VALIDATION_ERROR, and details.issues lists the problems. A rejected address or unavailable delivery returns the same code with the reason in error. See Errors.
| Endpoint | Request |
|---|---|
| Get webhook settings | GET/v1/agents/:id/post-call-webhook |
| Save webhook settings | PUT/v1/agents/:id/post-call-webhook |
| List deliveries | GET/v1/agents/:id/post-call-webhook/deliveries |
| Send a test event | POST/v1/agents/:id/post-call-webhook/test |
The webhook settings object
Attributes
deliveryAvailablebooleanrequiredfalse, config is null, deliveries is empty, and saving or testing returns 400.configobject | nullrequirednull until you first save them.config.urlstringconfig.enabledbooleanconfig.versionstringexpectedVersion on your next change or test.config.hasSigningSecretbooleantrue for any saved configuration. The secret itself is never returned.config.updatedAtstringdeliveriesarrayrequiredThe delivery receipt object
A receipt records what happened to one event. It holds metadata only, never the transcript, your endpoint's response or the address. Receipts are removed after 30 days.
Attributes
idstringrequiredX-Synthesys-Event-Id header and the payload's id.call_idstring | nullrequirednull for webhook.test.event_typestringrequiredcall.completed, call.updated, call.recording_ready or webhook.test.statusstringrequiredpending, delivered, failed or canceled.attemptsintegerrequiredlast_http_statusinteger | nullrequirednull when no status was received.last_errorstring | nullrequiredcreated_atstringrequireddelivered_atstring | nullrequiredGet webhook settings
/v1/agents/:id/post-call-webhookwrite keyReturns the agent's settings and its 10 most recent delivery receipts. A read key gets 403.
curl https://backend.synthesys.app/api/v1/agents/3fa85f64-5717-4562-b3fc-2c963f66afa6/post-call-webhook \
-H "SYNTH_API_KEY: YOUR_API_KEY"{
"success": true,
"data": {
"deliveryAvailable": true,
"config": {
"url": "https://example.com/webhooks/synthesys",
"enabled": true,
"version": "5d0c7b1e-2f64-4a39-b8e7-1c9a6f3d2e80",
"hasSigningSecret": true,
"updatedAt": "2026-10-30T12:00:00+00:00"
},
"deliveries": [
{
"id": "e7a1c2d4-6b3f-4e58-9a07-3d1f8c5b6a29",
"call_id": "b1f4d8a3-7c2e-4d96-8e15-0a6c3f9b2d47",
"event_type": "call.completed",
"status": "delivered",
"attempts": 1,
"last_http_status": 200,
"last_error": null,
"created_at": "2026-10-31T12:00:05+00:00",
"delivered_at": "2026-10-31T12:00:07+00:00"
}
]
}
}Save webhook settings
/v1/agents/:id/post-call-webhookwrite keyCreates or updates the agent's settings. Returns the same shape as Get webhook settings.
Body
urlstringrequiredenabledbooleanrequiredexpectedVersionstring | nullrequirednull on the first save. After that, the version from your latest response.Other body fields are rejected. You can't supply your own signing secret.
The first save generates the secret. Later saves keep it, and it can't be rotated. To copy it, open the agent's settings in the dashboard and choose Reveal signing secret. Only workspace admins can do this. No /v1 route returns it.
Changing url or enabled issues a new version and cancels events still pending. Calls that ended before the change don't send events, so nothing is backfilled. Sending the same values again changes nothing.
| Status | When |
|---|---|
| 400 | The body or address is invalid, the hostname can't be resolved, or delivery is unavailable in this environment. |
| 403 | The key lacks write permission, or its owner has the viewer role, whatever the key's permissions. |
| 404 | The agent doesn't exist or belongs to another account. |
| 409 | expectedVersion is out of date. Read the settings again and retry. |
curl -X PUT https://backend.synthesys.app/api/v1/agents/3fa85f64-5717-4562-b3fc-2c963f66afa6/post-call-webhook \
-H "SYNTH_API_KEY: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/webhooks/synthesys", "enabled": true, "expectedVersion": null}'{
"success": true,
"data": {
"deliveryAvailable": true,
"config": {
"url": "https://example.com/webhooks/synthesys",
"enabled": true,
"version": "5d0c7b1e-2f64-4a39-b8e7-1c9a6f3d2e80",
"hasSigningSecret": true,
"updatedAt": "2026-10-30T12:00:00+00:00"
},
"deliveries": []
}
}{
"success": false,
"error": "Settings changed. Reload before saving.",
"code": "CONFLICT",
"requestId": "0f6b2c9e-1d47-4a83-b5e2-7c3a9d1f4e60",
"timestamp": "2026-10-31T12:00:00.000Z"
}List deliveries
/v1/agents/:id/post-call-webhook/deliverieswrite keyReturns the agent's 10 most recent delivery receipts, newest first. This route has no pagination, so you can't read older receipts. A read key gets 403.
curl https://backend.synthesys.app/api/v1/agents/3fa85f64-5717-4562-b3fc-2c963f66afa6/post-call-webhook/deliveries \
-H "SYNTH_API_KEY: YOUR_API_KEY"{
"success": true,
"data": {
"deliveryAvailable": true,
"deliveries": [
{
"id": "a2c9e4f1-5d38-4b76-8f02-6e1b7d3c9a58",
"call_id": "b1f4d8a3-7c2e-4d96-8e15-0a6c3f9b2d47",
"event_type": "call.recording_ready",
"status": "pending",
"attempts": 2,
"last_http_status": 503,
"last_error": "Endpoint returned HTTP 503",
"created_at": "2026-10-31T12:03:00+00:00",
"delivered_at": null
}
]
}
}Send a test event
/v1/agents/:id/post-call-webhook/testwrite keyQueues a webhook.test event with synthetic data. No call is placed, and it works while the webhook is disabled.
Requests within the same clock minute reuse one event per settings version. They return the same deliveryId and don't send again. Check the outcome with List deliveries.
Body
expectedVersionstringrequiredversion from your settings.| Status | When |
|---|---|
| 400 | The body is invalid, or delivery is unavailable in this environment. |
| 403 | The key lacks write permission, or its owner has the viewer role, whatever the key's permissions. |
| 404 | The agent doesn't exist or belongs to another account. |
| 409 | expectedVersion is out of date, or the webhook has never been saved. |
curl -X POST https://backend.synthesys.app/api/v1/agents/3fa85f64-5717-4562-b3fc-2c963f66afa6/post-call-webhook/test \
-H "SYNTH_API_KEY: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"expectedVersion": "5d0c7b1e-2f64-4a39-b8e7-1c9a6f3d2e80"}'{
"success": true,
"data": { "deliveryId": "d4b8f1a6-3e92-4c57-a0d1-8f2e6b7c9a13" }
}Event types
An event is sent for a call with this agent once the call's status is ended or failed, including test calls. The webhook must be enabled, and the call must have ended after the settings last changed. Calls older than 30 days don't produce events.
| Event | When it is sent |
|---|---|
call.completed | The first event for a call. It waits up to 60 seconds for the transcript and end time, and is sent as soon as both are saved. |
call.updated | The call's details changed after call.completed was picked up for sending. |
call.recording_ready | A recording became ready after call.completed was picked up for sending. |
webhook.test | You called Send a test event. |
A recording that is ready before call.completed is sent is included in it. A call that never gets a transcript, such as an unanswered call, still sends call.completed after the wait. Its transcript and endedAt can be null.
Every event carries a full snapshot of the call, not a change list. Events can arrive late or out of order, so use createdAt to find the newest snapshot.
Event payload
Every event is a POST with a JSON body. Values that aren't available are null.
Body
idstringrequiredtypestringrequiredversionintegerrequired1.createdAtstringrequiredtestbooleanrequiredtrue only for webhook.test.callobjectrequiredcall
idstringrequiredwebhook.test, a random UUID that matches no call.agentIdstringrequiredstatusstringrequiredended or failed.directionstring | nullrequiredinbound or outbound.typestring | nullrequiredinboundPhoneCall, outboundPhoneCall, webCall or websocketCall. test for webhook.test. See Value mapping.isTestCallboolean | nullrequiredcustomerPhonestring | nullrequiredstartedAtstring | nullrequiredendedAtstring | nullrequireddurationSecondsnumber | nullrequiredendedReasonstring | nullrequiredtranscriptstring | nullrequiredsummarystring | nullrequiredanalysisobject | nullrequiredrecordingsarrayrequiredrecordings[].idstringrequiredrecordings[].urlstring | nullrequirednull if the recording was deleted or is unavailable.recordings[].expiresAtstring | nullrequired{
"id": "e7a1c2d4-6b3f-4e58-9a07-3d1f8c5b6a29",
"type": "call.completed",
"version": 1,
"createdAt": "2026-10-31T12:00:05.123456+00:00",
"test": false,
"call": {
"id": "b1f4d8a3-7c2e-4d96-8e15-0a6c3f9b2d47",
"agentId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "ended",
"direction": "outbound",
"type": "outboundPhoneCall",
"isTestCall": false,
"customerPhone": "+12025550123",
"startedAt": "2026-10-31T11:57:30+00:00",
"endedAt": "2026-10-31T12:00:02+00:00",
"durationSeconds": 152,
"endedReason": "customer-ended-call",
"transcript": "AI: Hello, this is Sarah.\nUser: Hi Sarah.",
"summary": "The customer booked a demo for Tuesday.",
"analysis": { "successEvaluation": "true" },
"recordings": [
{
"id": "f3a7c1e9-2b64-4d08-b5f1-9c8e7d6a3b12",
"url": "https://example.com/signed/recording.mp3",
"expiresAt": "2026-10-31T13:00:07.000Z"
}
]
}
}The webhook.test body has test: true. Its call has direction set to outbound, type and endedReason set to test, isTestCall set to true, a 30-second duration, a null customerPhone and no recordings.
Value mapping
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, so a failed connection can read provider-failed-to-connect-call. Other values, such as customer-ended-call, are unchanged.
Call logs and recordings use the same values, so you can compare them directly.
Request headers
| Header | Value |
|---|---|
X-Synthesys-Event-Id | The event's UUID. Use it to remove duplicates. |
X-Synthesys-Timestamp | When this attempt was signed, in Unix seconds. It changes on each retry. |
X-Synthesys-Signature | v1= followed by a lowercase hex HMAC-SHA256 signature. |
Content-Type | application/json |
User-Agent | Synthesys-Webhook/1.0 |
Verify the signature
Compute an HMAC-SHA256 over {timestamp}.{rawRequestBody}. Use your full signing secret as the key, including its whsec_ prefix. Verify the raw bytes before parsing the JSON. Use a constant-time comparison, and reject timestamps more than five minutes old.
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody is the request body as received, before JSON parsing.
export function verifyWebhook(rawBody, timestamp, signature, secret) {
if (!timestamp || !signature) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!(age <= 300)) return false;
const digest = createHmac('sha256', 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);
}Retries
Return a 2xx status within 10 seconds. An event is sent up to five times in total. Retries use exponential backoff with jitter, and the delay between attempts is capped at about 15 minutes. If the fifth send fails, the event is marked failed.
| Result of an attempt | What happens |
|---|---|
| 2xx | Marked delivered. |
| 429 or 5xx | Retried. |
| Timeout or network failure | Retried. |
| Any other status, including 3xx and other 4xx | Marked failed with no retry. Redirects are not followed. |
| Address no longer resolves only to public addresses | Marked failed with no retry. |
Delivery is at least once, so your endpoint can receive the same event twice. Remove duplicates by id, and record an event as processed only after you have applied it. The event's id and call snapshot stay the same on retries. The timestamp, signature and recording links are refreshed.
Before each attempt, the current settings are checked. If they changed, or the webhook was disabled, a pending event is marked canceled and isn't sent. webhook.test events still send while disabled.
There is no route to resend an event. Use Call logs to fetch results you missed.
Delivery errors
These are the values you can see in last_error.
| Value | Meaning |
|---|---|
Endpoint returned HTTP {status} | Your endpoint returned a non-2xx status. |
Delivery failed; check endpoint availability | A connection failure, timeout or other error stopped the send. |
Destination is not a public HTTPS endpoint | The address no longer passes the address checks. |
Settings changed before delivery | The settings changed while the event was pending. |
Webhook disabled or settings changed | The webhook was disabled, changed or removed before the attempt. |
Delivery job ended before confirmation | Delivery stopped before the result was confirmed. Your endpoint may or may not have received it. |