Webhooks
Get each call's result pushed to your server the moment it ends.
Instead of polling, register an HTTPS URL and Resonera sends it an event whenever something happens: a call ends, its recording is ready, or a campaign finishes.
Events
| Event | Sent when | data contains |
|---|---|---|
call.ended | A call finishes, whether it connected or not. | The call, as GET /calls returns it: status, outcome, summary, duration and your contact.extra. |
call.recording_ready | The call's recording has been saved, usually within a minute of call.ended. | The same call, now with has_recording: true. |
campaign.completed | Every contact in a campaign has been called. | The campaign, as GET /campaigns/{id} returns it, with contacts_by_status. |
Transcripts aren't included, to keep deliveries small. Fetch GET /calls/{call_id} when you need
one.
Set up an endpoint
Create a key
Give it the webhooks:manage scope.
Register your URL
curl -X POST https://api.resonera.ai/webhooks/endpoints \
-H "X-API-Key: $RESONERA_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://crm.example.com/resonera/webhook", "events": ["call.ended"]}'The response includes secret (whsec_…). Store it now: it's shown only once, and you
need it to verify deliveries. Leave out events to receive all of them.
Send a test
curl -X POST https://api.resonera.ai/webhooks/endpoints/$ENDPOINT_ID/test \
-H "X-API-Key: $RESONERA_KEY"Your endpoint receives a ping event. Check the outcome with
GET /webhooks/endpoints/{endpoint_id}/deliveries.
The URL must use HTTPS on a public host. Addresses on private networks are refused.
What a delivery looks like
A POST with a JSON body:
{
"id": "evt_7b74f0680dbb4562b2ca6d6a526fa1cd",
"type": "call.ended",
"created_at": "2026-10-05T09:14:13.412Z",
"data": {
"call_id": "88433d99-ead1-4ee2-86c1-7a2cbfd6d064",
"campaign_id": "3f1d9a52-7c0e-4b8a-a1f3-5e2c9d8b7a64",
"agent_id": "8b8e3307-2c1a-4f6e-9a51-0d7c2b9e4f10",
"agent_name": "Neha",
"direction": "outbound",
"phone_number": "+919876543210",
"status": "completed",
"end_reason": "completed",
"outcome": "requirements_gathered",
"summary": "Wants a demo on Friday afternoon.",
"error": null,
"duration_seconds": 93.2,
"start_time": "2026-10-05T09:12:40+00:00",
"end_time": "2026-10-05T09:14:13+00:00",
"has_recording": false,
"contact": { "name": "Asha", "extra": { "crm_id": "42" } }
}
}With these headers:
| Header | Value |
|---|---|
Resonera-Event-Id | The event's id. The same on every retry of the same event. |
Resonera-Event-Type | The event's type. |
Resonera-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256>. See below. |
Verify the signature
Check every delivery before trusting it. The signature is an HMAC-SHA256, keyed with your
endpoint's secret, over the timestamp, a ., and the raw request body:
v1 = hex( HMAC_SHA256( secret, t + "." + raw_body ) )Compare it in constant time, and reject timestamps more than five minutes old so a captured request can't be replayed.
import hashlib, hmac, os, time
from flask import Flask, request, abort
SECRET = os.environ["RESONERA_WEBHOOK_SECRET"].encode()
app = Flask(__name__)
def verify(raw_body: bytes, header: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(SECRET, f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
@app.post("/resonera/webhook")
def webhook():
if not verify(request.get_data(), request.headers.get("Resonera-Signature", "")):
abort(400)
event = request.get_json()
if event["type"] == "call.ended":
save_call(event["data"]) # your code; keep it quick
return "", 204Verify against the raw bytes you received. Parsing the JSON and serializing it again changes spacing and key order, and the signature won't match.
Responding and retries
Return any 2xx status within 10 seconds. Do slow work, such as calling your CRM, after you've
responded or in a background job.
Anything else (an error status, a timeout, a refused connection) is retried with increasing
gaps: after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 18 hours. That's 8
attempts over about a day and a half, after which the delivery is marked failed. Redirects are
not followed, so register the final URL.
GET /webhooks/endpoints/{endpoint_id}/deliveries shows each delivery's status, attempts, last
response code and next retry time.
Handling duplicates and order
- Duplicates: a delivery can arrive more than once, for example if your server took the
request but timed out before responding. Use
Resonera-Event-Idto ignore events you've already processed. - Order: events can arrive out of order, especially around retries.
call.recording_readycan, rarely, arrive beforecall.ended. Use the timestamps indata, and treat each event as an update to the record rather than a step in a sequence.
Managing endpoints
- Pause with
PATCH {"enabled": false}. Events that happen while it's disabled aren't queued for it. - Rotate the secret with
POST /webhooks/endpoints/{endpoint_id}/rotate-secretif it leaks. Deliveries signed after that use the new secret, so update your receiver promptly. - Delete with
DELETE /webhooks/endpoints/{endpoint_id}. Pending retries are dropped too.
Up to 10 endpoints per account. Each receives the events it subscribes to.