InfraVoice

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/v1 Auth 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.

Shell
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.

Shell
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.

Shell
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

MethodEndpointPurposeRole
PUT/call-center/settings?workspace_id=…Set webhook_url, rotate the secretowner/admin
GET/call-center/settings?workspace_id=…Read webhook_url, has_webhook_secretany member
POST/call-center/webhooks/test?workspace_id=…Send a test event through the real pipelineowner/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 attemptany member
POST/call-center/webhooks/deliveries/{id}/retry?workspace_id=…Re-send a dead delivery, same event idowner/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

JSON
{
  "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

ValueMeaning
completedRan to a normal end
failedThe carrier never connected it (busy, no answer, rejected)
abandonedEnded 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.

ValueMeaning
completeSummary and QA score both present
partialOne of them arrived, the other did not
timeoutNeither arrived before the hold expired
skippedA 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.

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:

HeaderExampleUse
Webhook-Signaturet=1757068951,v1=6a3f…Verify authenticity
Webhook-Timestamp1757068951Reject replays
Webhook-Id9c2f5e18-…Deduplicate on this
Idempotency-Keysame as Webhook-IdFor frameworks that look for this name
Webhook-Event-Typecall.completedRoute without parsing the body

The signature is HMAC-SHA256 over the timestamp, a literal dot, and the raw request body, keyed by your secret:

Text
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.

Python
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 ==
JavaScript
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 attemptNext attempt in
130 seconds
22 minutes
310 minutes
430 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.

ResponseWhat we do
2xxDelivered
408, 425, 429, any 5xxRetry
Timeout, DNS or TLS failureRetry
Any other 4xxDead immediately — the request itself is wrong, retrying will not fix it
Any 3xxDead 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.

SymptomUsual cause
Nothing arrives, no deliveries loggedNo webhook_url saved, or the workspace has had no completed calls since you saved it
Everything dead with 400/401/403Your endpoint rejects our request. Check the route exists and any auth it requires
Everything dead with a 3xxYour URL redirects. Configure the final destination
Signature never matchesYou 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 addressIt 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.started or call.transferred. Only call.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.