Webhooks
Be told when a call is ready instead of polling for it.
Register an endpoint and we'll POST to it when a call finishes processing, so you don't have to poll.
Registering an endpoint
curl -X POST "$BASE/v1/projects/$PROJECT_ID/webhooks" \
-H "X-API-Key: $AFTERTALK_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://your-app.example.com/hooks/aftertalk","events":["call.processed","call.failed"]}'{
"id": "3f7c...",
"url": "https://your-app.example.com/hooks/aftertalk",
"events": ["call.processed", "call.failed"],
"active": true,
"secret": "whsec_9f2c1e...",
"createdAt": "2026-07-25T10:00:00Z"
}The secret is returned once, at creation. Store it — you need it to verify signatures, and listing webhooks won't
show it again.
The URL must be public HTTPS. Plain http, loopback, private ranges, and cloud-metadata
addresses are rejected with 400 — the same check runs again before every delivery, so repointing
DNS at an internal address after registering won't work either.
Events
| Event | When |
|---|---|
call.processed | A call reached a terminal state with results saved |
call.failed | Processing failed; no transcript was produced |
Payload
Payloads are intentionally small — ids and a summary, not the transcript:
{
"id": "8f14e45f-ea0f-4e21-9c1a-2b3c4d5e6f70",
"type": "call.processed",
"createdAt": "2026-07-25T10:04:12Z",
"data": {
"callId": "b7e2...",
"projectId": "1c3a...",
"jobId": "8f14...",
"status": "COMPLETED",
"category": "billing",
"sentiment": "neutral"
}
}Fetch the call with GET /calls/{callId} for the transcript and full analysis. Keeping the
transcript out of the request means we're not pushing conversation content to your URL, and you
always read current data rather than a snapshot from send time.
status can be COMPLETED or BILLING_FAILED. Both mean results are readable — see
getting results.
These headers come with every delivery:
| Header | Contents |
|---|---|
X-AfterTalk-Signature | t=<unix seconds>,v1=<hmac> |
X-AfterTalk-Event-Id | Event id — the same value as id in the body |
X-AfterTalk-Event-Type | e.g. call.processed |
Verifying the signature
Always verify before acting on a delivery — otherwise anyone who learns your URL can post to it.
The signature is HMAC-SHA256 of "<timestamp>.<raw body>", keyed with your secret. The timestamp
is inside the signed string, so it can't be swapped for a fresh one; compare it against your own
clock and reject anything stale.
import crypto from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function verify(rawBody, header, secret) {
const parts = new Map(header.split(',').map((p) => p.split('=')));
const timestamp = Number(parts.get('t'));
const signature = parts.get('v1');
if (!timestamp || !signature) return false;
// Reject replays of an old, otherwise-valid request.
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
// Constant-time compare, so a wrong guess can't be narrowed down by timing.
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import hashlib, hmac, time
TOLERANCE_SECONDS = 300
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
try:
timestamp = int(parts["t"])
signature = parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode(),
f"{timestamp}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)Verify against the raw request body, before any JSON parsing and re-serialising. Re-encoding changes the bytes and the signature won't match.
Retries and idempotency
A delivery counts as successful on any 2xx. Anything else — including a timeout — is retried with
exponential backoff (about a minute, then doubling, capped at hourly) up to six attempts, after which
the delivery is marked failed.
That means the same event can arrive more than once. Treat X-AfterTalk-Event-Id as an
idempotency key and ignore ids you've already handled:
if (await alreadyHandled(eventId)) return res.status(200).end();Answer quickly — respond 2xx as soon as you've stored the event and do your own work afterwards.
Requests time out after 10 seconds, and a slow endpoint reads as a failure.
Redirects are not followed: register the final URL.
Debugging a webhook that isn't firing
Every attempt is recorded with its status code and error:
curl "$BASE/v1/projects/$PROJECT_ID/webhooks/$WEBHOOK_ID/deliveries" \
-H "X-API-Key: $AFTERTALK_KEY"Check in this order:
- No deliveries at all — is the webhook
active, and does it subscribe to the event? A paused webhook receives nothing. PENDING— queued, waiting for the next sweep or a scheduled retry.FAILEDwith a status code — your endpoint answered non-2xx;responseCodeis what it sent.FAILEDwith no status code — we couldn't reach it at all: DNS, TLS, timeout, or the URL no longer resolving to a public address.errorMessagesays which.
Pause a webhook while you're fixing your receiver rather than letting attempts burn down:
curl -X PATCH "$BASE/v1/projects/$PROJECT_ID/webhooks/$WEBHOOK_ID/toggle" \
-H "X-API-Key: $AFTERTALK_KEY"