Webhooks
When a call finishes, InfraVoice can POST the whole record to a URL you own — the summary, the QA score, the disposition, who handled it, and links to the transcript and recording. Point it at Zapier, n8n, your CRM, or your own service, and calls land in your systems without anyone exporting anything.
One event ships today: call.completed, sent once per call.
Base URL:
https://ultravoice-dev.us.inc/api/v1Auth header:Authorization: Bearer usf-…(your organization key, from Account → Organization key) Workspace: every endpoint here takes?workspace_id=…. It is what scopes the delivery log to your own calls. Where to configure it: Call Center → Settings → Post-call webhook, or the settings API below.
Set it up
Three steps, and the last one proves it works before a real call depends on it.
1. Save your endpoint. It must be https. Public addresses only — private, loopback and link-local addresses are refused when you save and again when we dial, so a hostname that later resolves inward cannot be used to reach inside our network.
curl -X PUT "https://ultravoice-dev.us.inc/api/v1/call-center/settings?workspace_id=$WS" \
-H "Authorization: Bearer $USF_KEY" \
-H "Content-Type: application/json" \
-d '{"webhook_url": "https://example.com/hooks/infravoice"}'2. Generate the signing secret. It is returned once, in this response only. Store it before you move on; there is no endpoint that reads it back.
curl -X PUT "https://ultravoice-dev.us.inc/api/v1/call-center/settings?workspace_id=$WS" \
-H "Authorization: Bearer $USF_KEY" \
-H "Content-Type: application/json" \
-d '{"rotate_webhook_secret": true}'
# → { "webhook_secret": "whsec_1f3c…", "has_webhook_secret": true, … }Afterwards GET /settings returns only has_webhook_secret: true. Rotating again issues a new secret and invalidates the old one immediately — there is no overlap window, so update your receiver in the same change.
3. Send a test event. This runs the real pipeline: the same signing, the same SSRF checks, the same delivery worker. If this succeeds, a real call will too.
curl -X POST "https://ultravoice-dev.us.inc/api/v1/call-center/webhooks/test?workspace_id=$WS" \
-H "Authorization: Bearer $USF_KEY"
# → { "delivery_id": "…" } then poll GET /webhooks/deliveries/{id}The test payload is shaped exactly like a real one but carries "test": true and obviously fake values, so your parser is genuinely exercised without a fake call appearing in your reports.
Two refusals are worth handling: 429 TEST_LIMIT means five of your test pings are still un-sent, so wait for those to finish rather than queueing more, and 503 WEBHOOKS_UNAVAILABLE means webhook delivery is switched off on our side — nothing would send the ping, so we refuse it instead of accepting one that could never leave the queue.
Endpoints
| Method | Endpoint | Purpose | Role |
|---|---|---|---|
PUT | /call-center/settings?workspace_id=… | Set webhook_url, rotate the secret | owner/admin |
GET | /call-center/settings?workspace_id=… | Read webhook_url, has_webhook_secret | any member |
POST | /call-center/webhooks/test?workspace_id=… | Send a test event through the real pipeline | owner/admin |
GET | /call-center/webhooks/deliveries?workspace_id=… | Delivery log (?status=, ?limit=, ?offset=) | any member |
GET | /call-center/webhooks/deliveries/{id}?workspace_id=… | One delivery plus every attempt | any member |
POST | /call-center/webhooks/deliveries/{id}/retry?workspace_id=… | Re-send a dead delivery, same event id | owner/admin |
Reading the log is open to any member because they are your own workspace's calls. Sending a test and retrying both cause an outbound request from our IP addresses, so they take the same gate as the setting that configured the endpoint.
?status= accepts pending, in_flight, delivered, dead and skipped. dead is the one to watch: it means we gave up. skipped means the endpoint was removed before we sent.
The event
{
"id": "9c2f5e18-4a71-4a2e-9b6f-0f3b2c7a1d55",
"type": "call.completed",
"created_at": "2026-09-04T10:22:31Z",
"test": false,
"workspace_id": "b0000000-0000-4000-b000-000000000001",
"data": {
"call_id": "…",
"conversation_id": "…",
"conversation_ids": ["…"],
"status": "completed",
"direction": "inbound",
"caller_number": "+971500000000",
"callee_number": "+971600000000",
"caller_name": "",
"started_at": "2026-09-04T10:18:12Z",
"answered_at": "2026-09-04T10:18:15Z",
"ended_at": "2026-09-04T10:22:20Z",
"duration_ms": 248000,
"talk_duration_ms": 235000,
"ai_agent": { "id": "…", "name": "Ava" },
"human_agents": [
{ "id": "…", "name": "Priya S", "segment_started_at": "…", "segment_ended_at": "…" }
],
"segments": [
{ "n": 1, "handler": "ai", "conversation_id": "…", "human_agent_id": "" },
{ "n": 2, "handler": "human", "conversation_id": "", "human_agent_id": "…" }
],
"disposition": { "code": "RESOLVED", "notes": "" },
"summary": "The caller asked to move their appointment…",
"qa_score": { "overall_score": 4.2, "flagged": false, "status": "scored" },
"analysis_status": "complete",
"transcript_url": "https://…/agents/…/conversations/…",
"recordings": [
{ "id": "…", "channel": "conference", "url": "https://…/recordings/…/audio" }
]
}
}Fields that can be null: disposition, summary, qa_score, transcript_url, answered_at, ended_at. A call nobody answered has no answered_at; a call the agent never wrote up has no disposition.
status — how the call ended
| Value | Meaning |
|---|---|
completed | Ran to a normal end |
failed | The carrier never connected it (busy, no answer, rejected) |
abandoned | Ended without a recorded completion, closed by our sweeper |
analysis_status — how complete the analysis is
The summary and QA score are produced by a model after the call ends, so we hold the event briefly to include them. This field tells you what you actually got.
| Value | Meaning |
|---|---|
complete | Summary and QA score both present |
partial | One of them arrived, the other did not |
timeout | Neither arrived before the hold expired |
skipped | A failed or abandoned call — analysis never runs on these |
A completed call typically arrives 5 to 20 seconds after hang-up. A call transferred to a person can take 90 to 150 seconds, because the human side is transcribed first. The hold never exceeds 4 minutes, after which the event ships with whatever exists.
Links, not blobs
transcript_url and each recording url are links, not content. They are proxy routes that re-authenticate on access, so fetch them with your organization key. We never send storage URLs directly, because those expire and would leak access if they did not.
transcript_url and each recording url are null when the deployment has no public base URL configured, and also when the one it has is a loopback address (http://localhost:9200, 127.0.0.1) — that link would only ever resolve on your machine, so we send null rather than a link you cannot follow. The rest of the event is unaffected.
Verify the signature
Every request carries these headers:
| Header | Example | Use |
|---|---|---|
Webhook-Signature | t=1757068951,v1=6a3f… | Verify authenticity |
Webhook-Timestamp | 1757068951 | Reject replays |
Webhook-Id | 9c2f5e18-… | Deduplicate on this |
Idempotency-Key | same as Webhook-Id | For frameworks that look for this name |
Webhook-Event-Type | call.completed | Route without parsing the body |
The signature is HMAC-SHA256 over the timestamp, a literal dot, and the raw request body, keyed by your secret:
signed_string = "<Webhook-Timestamp>" + "." + <raw body bytes>
v1 = hex(HMAC_SHA256(secret, signed_string))Sign the bytes as received. If your framework re-serializes JSON before you see it, the signature will not match — read the raw body.
import hashlib, hmac, time
def verify(raw_body: bytes, sig_header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in sig_header.split(","))
ts, sent = parts.get("t", ""), parts.get("v1", "")
if not ts or not sent:
return False
if abs(time.time() - int(ts)) > 300: # reject anything older than 5 minutes
return False
expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sent) # constant time, never ==const crypto = require('crypto');
function verify(rawBody, sigHeader, secret) {
const parts = Object.fromEntries(sigHeader.split(',').map(p => p.split('=')));
const { t, v1 } = parts;
if (!t || !v1) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto.createHmac('sha256', secret)
.update(t + '.').update(rawBody).digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}Delivery guarantees
Answer 2xx quickly. Any 2xx is success. Do the work afterwards, not before you reply — we wait up to 10 seconds per attempt.
Delivery is at least once. Deduplicate on Webhook-Id. It is the same value across every retry of the same call, and it is also the id in the body. A crash between your 2xx and our bookkeeping causes one repeat, so a receiver that ignores the id will double-process eventually.
Retries. Five attempts, then the delivery is marked dead.
| After attempt | Next attempt in |
|---|---|
| 1 | 30 seconds |
| 2 | 2 minutes |
| 3 | 10 minutes |
| 4 | 30 minutes |
Each wait carries up to 20% jitter so a recovering endpoint is not hit by a synchronised wave. A Retry-After header on a 429 or 503 overrides the schedule, up to one hour.
| Response | What we do |
|---|---|
2xx | Delivered |
408, 425, 429, any 5xx | Retry |
| Timeout, DNS or TLS failure | Retry |
Any other 4xx | Dead immediately — the request itself is wrong, retrying will not fix it |
Any 3xx | Dead immediately — we do not follow redirects; give us the final URL |
A retry never carries more data than the first attempt did. The body is built once and re-sent byte for byte, so a retry is identical to the attempt that failed — including a retry an operator triggers by hand from the dashboard days later, even if the summary or QA score landed in the meantime. This is deliberate: it keeps the signature verifiable and your deduplication honest.
Ordering is not guaranteed. A retried event can arrive after a later one. Use ended_at if order matters.
Nothing is lost while your endpoint is down. Deliveries queue and drain when you recover. Anything that exhausted its retries stays in the log as dead, and you can re-send it with the retry endpoint — same event id, so your deduplication still holds.
When something is wrong
Start at Call Center → Settings → Post-call webhook, which lists recent deliveries with the response code and error, or read the same data from GET /webhooks/deliveries?status=dead.
| Symptom | Usual cause |
|---|---|
| Nothing arrives, no deliveries logged | No webhook_url saved, or the workspace has had no completed calls since you saved it |
Everything dead with 400/401/403 | Your endpoint rejects our request. Check the route exists and any auth it requires |
Everything dead with a 3xx | Your URL redirects. Configure the final destination |
| Signature never matches | You are hashing a re-serialized body. Hash the raw bytes |
summary and qa_score always null, analysis_status: "timeout" | The agent has summaries or QA scoring turned off, so nothing is ever produced to wait for |
| Saving a URL returns an error about the address | It is not https, or it resolves to a private address |
Not in this version
Worth knowing before you build against it:
- One endpoint per workspace, one event type. Per-event subscriptions and multiple endpoints are planned.
- No
call.startedorcall.transferred. Onlycall.completed. - A disposition saved after the event has already shipped is not re-sent. Agents can write up a call minutes later; if you need that, read the call back from the API.
- Calls with no call-centre record produce no webhook. Browser and voice-test sessions are not covered.
- Campaign outcomes that never became a call record (a number that was never connected) do not produce an event.