For Developers
Calls

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.

  1. Set up an endpoint

    Your endpoint must accept POST requests 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 2xx status within 10 seconds. Redirects aren't followed.

    Use the verification code below as a starting point.

  2. Save the webhook

    Send the URL with PUT /v1/agents/:id/post-call-webhook, using a key with write permission. On the first save, set expectedVersion to null.

    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.version from the response. You need it to send a test and to save further changes.

    JSON
    {
      "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": []
      }
    }
  3. 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.

  4. Send a test

    POST /v1/agents/:id/post-call-webhook/test queues a webhook.test event 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" }'
    JSON
    {
      "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 deliveryId and sends nothing new.

    Then read the delivery log to see the result.

Read the event

Every request has these headers:

HeaderValue
X-Synthesys-Event-IdThe event's UUID. It stays the same across retries and matches id in the body.
X-Synthesys-TimestampUnix time in seconds when this attempt was signed.
X-Synthesys-Signaturev1= followed by the hex signature.
Content-Typeapplication/json
User-AgentSynthesys-Webhook/1.0

The body looks like this:

JSON
{
  "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

idstring
The event's UUID. Use it to detect duplicates.
typestring
call.completed, call.updated, call.recording_ready or webhook.test.
versioninteger
The payload version, currently 1.
createdAtstring
When the event was created.
testboolean
true only for webhook.test events.
call.idstring
The call log's UUID. Use it as the :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.agentIdstring
UUID of the agent.
call.statusstring
ended or failed.
call.directionstring
inbound or outbound, or null.
call.typestring
The kind of call, such as outboundPhoneCall, or null. See Mapped values.
call.isTestCallboolean
Whether this was a test call, or null.
call.directCallIdstring
The callId from POST /v1/calls, or null for calls placed another way.
call.customerPhonestring
The other party's number, or null.
call.startedAtstring
When the call started, or null.
call.endedAtstring
When the call ended, or null.
call.durationSecondsinteger
Length in seconds, or null.
call.endedReasonstring
Why the call ended, or null. See Mapped values.
call.transcriptstring
The full transcript as text, or null.
call.summarystring
The call summary, or null.
call.analysisobject
The post-call analysis, or null. Don't rely on a fixed shape.
call.recordingsarray
The call's ready recordings, each with id, 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

EventSent when
call.completedThe call first finishes. This fires once per call and webhook version, including for failed calls.
call.updatedA later change adds or corrects call details.
call.recording_readyA recording becomes available after call.completed has gone out.
webhook.testYou 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.

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:

  1. Read the raw request body before any JSON parsing. Re-encoded JSON won't match.
  2. Compute an HMAC-SHA256 of timestamp + "." + rawBody, using your signing secret as the key.
  3. Hex-encode the result and put v1= in front of it.
  4. Compare that with X-Synthesys-Signature using a constant-time comparison.
  5. 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 endpointWhat happens
Returns 2xxDelivered.
Returns 429 or 5xxRetried.
Times out after 10 seconds or can't be reachedRetried.
Returns any other status, including 3xx and other 4xxFailed, with no retry.
Resolves to a non-public addressFailed, 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-Id you'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/:id when 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"
JSON
{
  "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

idstring
The event ID, matching X-Synthesys-Event-Id.
call_idstring
The call's UUID, or null for a test.
event_typestring
The event type.
statusstring
pending, delivered, failed or canceled. An event waiting for a retry is pending.
attemptsinteger
How many times it has been sent.
last_http_statusinteger
The status your endpoint last returned, or null.
last_errorstring
Why the last attempt failed, or null. For example, Endpoint returned HTTP 500.
created_atstring
When the event was created.
delivered_atstring
When your endpoint accepted it, or null.

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

StatusRouteCause
400AllThe agent ID isn't a UUID.
400Save, testThe 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.
403AllThe API key doesn't have write permission. A read key can't read webhook settings or deliveries.
404AllThe agent doesn't exist in your account.
409Save, testexpectedVersion doesn't match the current version. A test also returns 409 if no webhook has been saved.