Guides

Webhooks

Pass a callbackUrl when you trigger a call and we POST the result to it when the call ends. For events across the organization, create a signed webhook endpoint.

How it arrives

A call with a callbackUrl gets one POST when it finishes, whether it completed or failed. This is the recommended shape for anything in production: the alternative is polling the calls endpoint, which costs a request every few seconds to learn nothing most of the time.

call.completed
The conversation ran to its end, by either side hanging up.
call.failed
It never got there, no answer, a timeout, or a connection that dropped.
Deliveries are per call, not per campaign. A campaign of four hundred calls is four hundred webhooks.

The payload

POST your-callback-url
{
"event": "call.completed",
"callId": "8f14e45f-ceea-467f-a830-cf60f5e5a214",
"campaignId": "357b188c-c618-4f95-84b1-cbe26b8ce3e0",
"phoneNumber": "+14155551234",
"status": "completed",
"duration": 127,
"endReason": "agent_hangup",
"analysis": {
"sentiment": {
"overall": "positive",
"score": 0.85,
"confidence": 0.92
},
"humanContact": {
"reached": true,
"type": "human",
"confidence": 0.95
},
"objectives": [
{
"id": "1",
"name": "Confirm order",
"achieved": true,
"confidence": 0.88
}
]
},
"metadata": {
"orderId": "123",
"source": "shopify"
},
"completedAt": "2026-03-04T14:32:07Z"
}
stringevent
call.completed or call.failed.
stringcallId
The call, which you can fetch for the transcript and recording.
stringcampaignId
The campaign the call was triggered from.
stringphoneNumber
The number that was called, in E.164.
stringstatus
The final status of the call.
numberduration
Seconds of conversation. null on a failed call.
stringendReason
Why it ended: hangup, agent_hangup, timeout, and so on.
objectanalysis
Sentiment, whether a human was reached, and which objectives were met.May be null: analysis runs after the call and can still be in flight when the webhook fires.
objectmetadata
Whatever you passed when triggering the call, returned untouched. This is how the result finds its way back to your own record.
stringcompletedAt
ISO 8601 timestamp of the end of the call.

What your endpoint must do

HTTPS
Plain HTTP callback URLs are refused at trigger time, not silently dropped later.
2xx within 10s
Anything else counts as a failure. Acknowledge first and do the work afterwards, a slow handler looks the same to us as a broken one.
Idempotent
Key on callId and ignore one you have already seen.Retries mean a delivery can arrive twice.

A failed delivery is retried three times (after 1 second, 5 seconds and 15 seconds) and then dropped. Delivery is fire-and-forget: it never affects the call, and a webhook you never received is still a call you can fetch.

A handler

webhooks/call-complete.js
app.post('/webhooks/call-complete', async (req, res) => {
const { event, callId, status, analysis, metadata } = req.body;
// Acknowledge first: the work below can outlast the 10 second budget.
res.status(200).json({ received: true });
if (await alreadyHandled(callId)) return;
if (event === 'call.completed' && analysis) {
await updateOrder(metadata.orderId, {
callCompleted: true,
sentiment: analysis.sentiment?.overall,
objectivesAchieved: analysis.objectives
?.filter((objective) => objective.achieved)
.map((objective) => objective.name),
});
}
await markHandled(callId, status);
});

Analysis can be null

If you need the analysis and it has not landed, fetch the call a minute later rather than treating the webhook as the last word.

Webhook endpoints

A callbackUrl covers one call. A webhook endpoint is an organization-wide subscription to a list of events, such as call.ended or call.analysis.completed, and every delivery to it is signed. The signing secret is returned once, when the endpoint is created and again when it is rotated.

X-Vocalis-Signature
t=<unix seconds>,v1=<hex>. Verify it before trusting the payload.
X-Vocalis-Event
The event type.
X-Vocalis-Event-Id
The same on every retry of one delivery.Use it as your idempotency key.

Failures are retried with exponential backoff. A 4xx other than 408 or 429 is treated as a refusal and not retried, and an endpoint that fails continuously is switched off.

Verify with the SDK

In Node.js, Bun, Deno or an edge runtime, the TypeScript SDK verifies the signature, rejects stale timestamps and types the event for you. For Next.js route handlers, Hono, Bun, Deno and Workers:

app/webhooks/vocalis/route.ts
import { createWebhookHandler } from "@pendral/vocalis/webhooks";
export const POST = createWebhookHandler({
secret: process.env.VOCALIS_WEBHOOK_SECRET!,
on: {
"call.analysis.completed": async (event) => {
const call = await vocalis.calls.get(event.callId!);
await saveOutcome(call);
},
},
});

Unsigned or stale deliveries get a 400 and never reach your code; a handler that throws answers 500 so the delivery is retried. For Express or anything else, verify directly:

import { constructEvent, WebhookVerificationError } from "@pendral/vocalis/webhooks";
app.post("/webhooks/vocalis", express.raw({ type: "application/json" }), async (req: Request, res: Response) => {
try {
const event = await constructEvent(req.body, req.headers, process.env.VOCALIS_WEBHOOK_SECRET!);
res.sendStatus(204);
} catch (error) {
if (error instanceof WebhookVerificationError) return res.status(400).send(error.message);
throw error;
}
});

Rotating the secret

Pass both secrets while you switch over, secret: [newSecret, oldSecret], and drop the old one once the rotation is done.

Verify by hand

In any other language the check is a few lines of HMAC:

  1. Read the header

    Split X-Vocalis-Signature on commas. t is the timestamp; each v1 is a candidate signature.

  2. Check the timestamp

    Reject the delivery if t is more than five minutes from now. This stops a captured request being replayed later.

  3. Compute the signature

    HMAC-SHA256 of <t>.<raw body>, keyed with the endpoint's secret, as lowercase hex. Use the body bytes exactly as received: parsing and re-serializing the JSON changes them.

  4. Compare

    Accept the delivery if any v1 equals your value, compared in constant time. Otherwise answer 400.

verify.py
import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = [part.split("=", 1) for part in header.split(",") if "=" in part]
timestamp = next((int(v) for k, v in parts if k.strip() == "t"), None)
signatures = [v.strip() for k, v in parts if k.strip() == "v1"]
if timestamp is None or not signatures or abs(time.time() - timestamp) > tolerance:
return False
message = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, candidate) for candidate in signatures)