/v1/webhooks.
- Outbound events are what Sempera sends to an HTTPS endpoint you register: signed JSON about calls, agents, test runs and alerts. This page is their contract: the events, the shape of each, how to verify a delivery, and what is and is not guaranteed.
- Provider ingress is where the telephony carrier and the payment provider deliver to Sempera, each under its own proof. You never call these (see the end of this page).
Outbound events
The events
GET /v1/webhooks lists your endpoints and, beside them, events: every event name you can subscribe to, in the order above. Every event in that list is one Sempera sends.
Every event goes only to endpoints of the workspace that owns the call, agent or test run, and only to endpoints that are enabled, not deleted and subscribed to it. An endpoint never receives another workspace’s event; the envelope’s workspace_id names the workspace it came from. metric_alert.triggered is never fanned out by subscription: an endpoint that lists it hears it only when an alert rule of the same workspace names that endpoint as a notification target.
session.completed is retired and was never sent: a preview session has no single end Sempera can announce. Asking for it is a validation_failed error that says so. Endpoints that stored it no longer list it; one that listed nothing else now lists no events and keeps its URL and secret. Use call.completed for phone calls.
Registering an endpoint
An endpoint reads
id, object (webhook_endpoint), url, description, events, enabled, has_secret, workspace_id, created_at, updated_at and post_call_filter. Leave events out on create and the endpoint subscribes to every event except metric_alert.triggered and batch.updated.
urlishttps, at most 2000 characters, with no user name or password and a valid port. A host inside a private network is refused when you save it, and again every time Sempera sends: the name is resolved and the connection is pinned to the address that was checked. Sempera never follows a redirect; a3xxanswer is a failed delivery.secretis required on create, 16 to 256 characters. It is write-only: stored encrypted, never returned;has_secretsays whether one is set. Change it withPATCH. Never rely on the URL being secret.- A member below the owner role, or a key without the scope, cannot write; another workspace’s endpoint answers
404on every route above.
Post-call targeting and resend
Endpoints without a selector remain account-wide. On create, setpost_call_filter to {"phone_number":"<owned E.164 number>"} or {"call_id":"<owned call id>"} and events to ["call.completed"] to narrow delivery. Selectors are immutable; replace the endpoint to change one. Another workspace’s number or call is not found.
POST /v1/calls accepts webhook, the URL of an enabled signed endpoint already registered in the workspace, with an empty post_call_filter. Only a confirmed dial creates a child subscription referencing that endpoint; it copies neither the URL nor the secret and is not another endpoint. Each delivery attempt checks the parent is enabled and not deleted and uses its current URL and signing secret. Pausing, deleting or rotating the parent therefore applies to its child subscriptions too. A workspace can have at most 1000 open child subscriptions. A child is removed once its call’s call.completed is handed to the delivery log, when its parent is deleted, or two days after confirmation if the call was never announced. A busy, unanswered or declined line releases its hold at once, and repeating that request with the same Idempotency-Key returns the same answer without dialing again. A dial whose outcome is unknown keeps its hold. Overlapping subscriptions to the same parent produce one delivery, not duplicate deliveries. webhook_events, when provided, must be ["call.completed"]. Other event options fail with a field-specific 400. Account and number subscriptions remain independent, so overlapping subscriptions can receive the same event.
POST /v1/webhooks/{id}/deliveries/{delivery_id}/resend requires owner permission, webhooks:write, and Idempotency-Key. It reuses the signed, SSRF-guarded delivery road for a single attempt, with no retry sleeps, and records the result in the normal delivery log. Content availability is checked again immediately before sending. Resend receipts omit caller content, so a late receipt cannot restore content deleted while the request was in flight. The resend has a new event id and resend_of naming the original event. A paused endpoint or a delivery whose call content was deleted cannot resend. Retrying the same request key returns its saved result without sending again.
Editing together
Two owners can open the same endpoint and both save. Without a guard the later save wins and the earlier one is lost without a word. To avoid that, send theupdated_at you read back with the edit:
expected_updated_at is the endpoint’s updated_at, sent back unchanged (a time with a zone). The endpoint is saved only if it still has that updated_at, and the check and the save are one step, so of two saves that carry the same value exactly one is applied. Every change PATCH takes is an edit and takes the same field: a new url, events, a pause or a resume (enabled), a new description, and a new secret. The field is a guard and is never stored on the endpoint.
If another owner saved first, the answer is 409 conflict with reason: "stale_webhook" and updated_at, the value now on file. Nothing is written: a new secret in that request is not stored either, and the error repeats no secret. Show the owner what changed, then either discard the edit or send it again with expected_updated_at set to that updated_at to save it over the newer version. A value that is not a time with a zone is 400 validation_failed with field: "expected_updated_at". An endpoint of another workspace, or one deleted meanwhile, is 404 not_found and names no updated_at.
Without the field a save behaves as it always did: the last save wins.
The envelope
Every delivery is one JSON object:
The body is UTF-8 JSON, compact, with its keys sorted, and it is the same bytes on every attempt of one event. Parse it as JSON and never depend on key order or on a field you do not know: new optional fields ship on
/v1 without notice (Migration and changelog). The examples below are indented for reading.
Event data
A field markedalways is in every event of its type. Any other field is absent (never null) when it does not apply. number or null and string or null are the only nullable fields.
call.completed
Sent once for each finished phone call, after its record is stored. Two roads send it:
- A call the carrier handled, when its post-call report is ingested and the call, its transcript and, when one was fetched, its recording are stored.
- A call Sempera’s own call service answered, when the call service’s record of the call is stored. A pass announces these once a minute, so the event usually arrives within a minute or two of the call ending; a call not announced within a day of ending is not announced.
providerissempera.
call_id, the call service’s session, the carrier’s own call id), so a report the carrier repeats, a record read again, or one call seen by both roads sends nothing more. A test is never announced: Try it live in the browser, the typed Test and the simulator send no call.completed. call.outcomes for the same call carries the same call_id. The workspace’s own Call completed triggers (/v1/automation) fire at the same moment, from the same announcement: once per call on either road, and never for a test. On a call Sempera’s own call service answered, a trigger’s outcome is the outcome the App’s Conversations list shows for the call and its summary is empty.
An interpreted call (Live translation, where it is switched on for a workspace: Sempera interprets between two people who do not share a language, such as your staff and a customer) is a real conversation, not a test, and is announced the same way, once, by the same pass. provider is sempera, duration_s is how long the session lasted, recording_stored is false because no audio of it is kept, and in logs each turn’s caller is what the caller said (null on your staff’s turns) and say is what Sempera said. It is a Session, not a phone Call: GET /v1/sessions/{call_id} reads it, on the interpreted channel, and GET /v1/calls/{call_id} answers 404. A trigger’s direction for it is interpreted, never inbound or outbound, and its outcome is the one the Conversations list shows. A session whose interpreter never started (nothing was interpreted) was never a call and is not announced.
call.outcomes
The post-call follow-up for one call, sent to the workspace’s subscribed endpoints. It is sent when the agent’s published outcomes are routed to the webhook, and whenever a published disposition ran on the call (Dispositions); a call with neither sends nothing. If no endpoint listens for it, the App shows the delivery as skipped, with the reason. The event arrives after the call is stored and read, normally after call.completed, and the two do not wait for each other: join them on call_id.
call_id with a new id: treat the later event as the fuller answer.
batch.updated
Sent after an outbound batch pass to endpoints explicitly subscribed to this event.
agent.published
test.completed
Sent when a test run ends, including one that crashed. Read the run with GET /v1/tests/{test_id}; its reason text stays there and never rides a webhook.
metric_alert.triggered
Sent to the endpoint an alert rule names, when the rule fires, worsens or recovers (Metric alerts). The rule’s notification target must be an enabled endpoint of the same workspace. The first attempt is made while the rule is evaluated; a failed first attempt is retried in the background on the schedule below.
webhook.test
Sent by POST /v1/webhooks/{id}/test, to that endpoint only, with one attempt and no retries. The route answers {"object": "webhook_test", "endpoint_id", "event_id", "status", "attempts", "response_code", "error"}, where status is delivered or failed, so you see at once what your endpoint did.
Signing and verifying
Every delivery carries these headers:
The signature is the lowercase hex HMAC-SHA256, keyed with your endpoint secret, of
"<t>." + raw_body, where t is the same number that appears in the header and raw_body is the exact bytes of the request body. Each attempt is signed at the moment it is sent, so t and v1 differ between attempts of one event while the body and X-Sempera-Event-Id do not. An endpoint saved without a secret (has_secret is false; none can be created through the API today) is sent no signature header. An endpoint whose stored secret cannot be read is never sent an unsigned request: the attempt fails in the delivery log with a sentence saying so, and nothing reaches your URL.
Verifying
- Take the raw request body bytes, exactly as received, before parsing.
- Parse
tandv1from the header. - Reject if
tis more than 300 seconds from your clock, in either direction. - Compute
HMAC_SHA256(secret, "<t>." + raw_body)as lowercase hex. - Compare with
v1in constant time. - Drop an
X-Sempera-Event-Idyou have already processed.
Signed requests without a shared secret
A workspace may also turn on JWT request signing (GET and PATCH /v1/signing, keys under /v1/signing/keys). Every delivery attempt then carries one more header, X-Sempera-JWT by default (the name and a prefix are configurable; the default value is Bearer <jwt>): a short-lived RS256 token over the same request. Its claims are iss, sub (the workspace), aud (the endpoint’s origin, or the audience you set), iat, nbf, exp, a fresh jti and body_sha256, the hex SHA-256 of the exact body bytes, plus any custom claims you set. Verify it against the public keys at GET /v1/workspaces/{workspace_id}/.well-known/jwks.json, which needs no credential. It adds to X-Sempera-Signature; it never replaces it.
A trigger’s webhook action (which names one of your endpoints and is signed with that endpoint’s own secret) and a widget’s chat webhooks (Website widgets, signed with the widget’s own secret) use this same X-Sempera-Signature scheme with the X-Sempera-Event-Id and X-Sempera-Event headers. They are not events of this catalogue and carry their own bodies.
A trigger’s webhook action is tried on the same three-attempt schedule, and a restart of the service between its attempts does not end it: once the service is back, the next attempt is sent within about a minute and a half of falling due, with the same event id and body, to the endpoint the trigger names while it is still enabled. An attempt a restart cut off counts as one of the three, marked as not known to have arrived. If the service was down for more than 15 minutes after the event, the run shows as stalled and the owner can send it again (POST /v1/automation/executions/{id}/retry).
Delivery, retries and idempotency
Your endpoint answers2xx within 10 seconds to accept a delivery. Any other answer, a timeout, a refused connection or an address that is not allowed is a failed attempt, including 4xx and a 3xx (a redirect is never followed). Sempera reads at most 64 KiB of your response and ignores it.
Schedule. An event is delivered in three attempts until the first 2xx: the first at once, the second 30 seconds after the first attempt ends, the third 5 minutes after the second ends. After the third failure Sempera stops. metric_alert.triggered follows the same schedule (its first attempt is made while the rule is evaluated). webhook.test is one attempt.
**call.outcomes has its own ladder.** Its deliveries are kept on disk as a ledger the App shows, and are made in up to three attempts: the second no sooner than 60 seconds after the first fails, the third no sooner than 120 seconds after the second, checked once a minute. If the third fails, the delivery stays failed until the workspace owner retries it from the App, which sends the same event again as one more attempt.
Retries are in memory. The other events’ retries are held by the process that sends them. If that process restarts between attempts, the remaining attempts are not sent, and nothing sends the event again by itself. Do not treat a webhook as the only record: reconcile against the API (GET /v1/calls, GET /v1/agents/{id}/versions, GET /v1/tests/{id}) when you need to be sure.
Idempotency. Delivery is at least once. A receiver can see the same event twice, for example when your 2xx arrives after the 10 second limit. X-Sempera-Event-Id (the envelope’s id) is the idempotency key: it is unique per event and identical on every attempt and every retry of that event. Record the ids you have processed and drop repeats; keeping them for at least a day covers every retry Sempera makes on its own. The delivery log keeps one row per (endpoint, event, attempt).
Ordering. No order is guaranteed, between different events or between an event and the retries of an older one. Each event is delivered on its own and every subscribed endpoint is sent independently, so a retried older event can arrive after a newer one. Order by created_at and join related events on call_id or agent_id instead of by arrival.
The delivery log
GET /v1/webhooks/{id}/deliveries lists the endpoint’s most recent 500 attempts, newest first, as {"object": "list", "data": [...], "has_more", "next_cursor"} (limit 1 to 200, default 50, and cursor). Each row:
The log carries no request bodies.
Inbound: provider ingress
A few routes under/v1/webhooks exist so that Sempera’s own service providers, the telephony carrier and the payment processor, can deliver events and call audio to Sempera. They are for those providers and not for you: you never call them, they are not part of the integration contract, and this page does not list them.
Each one accepts only its provider’s own proof: a signature over the request, a shared token compared in constant time, or a single-use token that expires within seconds. Repeating a payment event or a post-call report does not repeat its effect, and a phone number that is not bound to a workspace stores nothing. A Sempera API key is never accepted on an ingress route.
The two that connect live call audio are public only on a deployment that has turned live call audio on; elsewhere they are closed like any other route. No other inbound routes exist.