API
API
Back to appIntroductionAuthenticationQuickstartCore conceptsUploading callsGetting resultsWebhooksPagination and searchErrorsRate limits and billing

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

EventWhen
call.processedA call reached a terminal state with results saved
call.failedProcessing 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:

HeaderContents
X-AfterTalk-Signaturet=<unix seconds>,v1=<hmac>
X-AfterTalk-Event-IdEvent id — the same value as id in the body
X-AfterTalk-Event-Typee.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.

Node
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);
}
Python
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:

  1. No deliveries at all — is the webhook active, and does it subscribe to the event? A paused webhook receives nothing.
  2. PENDING — queued, waiting for the next sweep or a scheduled retry.
  3. FAILED with a status code — your endpoint answered non-2xx; responseCode is what it sent.
  4. FAILED with no status code — we couldn't reach it at all: DNS, TLS, timeout, or the URL no longer resolving to a public address. errorMessage says 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"

Getting results

Track a job to completion and read the analysis.

Pagination and search

Paging through calls, filtering by date, and full-text search.

On this page

Registering an endpointEventsPayloadVerifying the signatureRetries and idempotencyDebugging a webhook that isn't firing