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.
The payload
{"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.completedorcall.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.
nullon 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
callIdand 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
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
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:
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
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:
Read the header
Split
X-Vocalis-Signatureon commas.tis the timestamp; eachv1is a candidate signature.Check the timestamp
Reject the delivery if
tis more than five minutes from now. This stops a captured request being replayed later.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.Compare
Accept the delivery if any
v1equals your value, compared in constant time. Otherwise answer400.
import hashlib, hmac, timedef 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 Falsemessage = f"{timestamp}.".encode() + raw_bodyexpected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()return any(hmac.compare_digest(expected, candidate) for candidate in signatures)