> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sempera.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> The outbound event contract (call.completed, call.outcomes, agent.published, test.completed, metric_alert.triggered, webhook.test), the payload of each, X-Sempera-Signature (HMAC-SHA256 over "{t}.{body}", 5-minute tolerance) with a verification recipe, retries, idempotency and ordering, the delivery log, and provider ingress.

Two directions live under `/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

| Category | Event | Sent when | Reaches |
| - | - | - | - |
| Call | `call.completed` | A phone call ended and was stored: the carrier's post-call report was ingested, or Sempera's own call service finished the call. An interpreted call is announced too | Endpoints subscribed to it |
| Call | `call.outcomes` | The values you asked to capture were read from a finished call, or a published disposition ran on it | Endpoints subscribed to it |
| Agent | `agent.published` | A new version of an agent was published, by the API or the App | Endpoints subscribed to it |
| Test | `test.completed` | A test run (`POST /v1/tests`, or the App's Test bench) finished or crashed | Endpoints subscribed to it |
| Alert | `metric_alert.triggered` | A metric alert rule fired, worsened or recovered ([Metric alerts](https://docs.sempera.org/alerts.md)) | Only the endpoint the rule names |
| Endpoint | `webhook.test` | You called `POST /v1/webhooks/{id}/test` | Only that endpoint |
| Batch | `batch.updated` | An outbound batch pass completed | Endpoints explicitly subscribed to it |

`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

```http theme={null}
POST /v1/webhooks
{"url": "https://example.com/sempera", "events": ["call.completed", "call.outcomes"], "secret": "<16 to 256 characters>"}
```

| Route | What it does | Needs |
| - | - | - |
| `GET /v1/webhooks` | Lists the workspace's endpoints (`limit` 1 to 200, default 50, and `cursor`), with `events` | `webhooks:read` |
| `POST /v1/webhooks` | Creates one; `201`. Accepts `Idempotency-Key` | `webhooks:write`, owner role |
| `GET /v1/webhooks/{id}` | Reads one | `webhooks:read` |
| `PATCH /v1/webhooks/{id}` | Changes `url`, `events`, `enabled`, `secret` or `description`. Optional `expected_updated_at` makes the save conditional (see Editing together) | `webhooks:write`, owner role |
| `DELETE /v1/webhooks/{id}` | Deletes it; it stops receiving at once | `webhooks:write`, owner role |
| `GET /v1/webhooks/{id}/deliveries` | The delivery log (below) | `webhooks:read` |
| `POST /v1/webhooks/{id}/test` | Sends one signed `webhook.test` now and answers with what your endpoint did | `webhooks:write`, owner role |

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

* `url` is `https`, 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; a `3xx` answer is a failed delivery.
* `secret` is required on create, 16 to 256 characters. It is write-only: stored encrypted, never returned; `has_secret` says whether one is set. Change it with `PATCH`. 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 `404` on every route above.

### Post-call targeting and resend

Endpoints without a selector remain account-wide. On create, set `post_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 the `updated_at` you read back with the edit:

```http theme={null}
PATCH /v1/webhooks/{id}
{"url": "https://example.com/sempera", "expected_updated_at": "2026-10-23T15:00:00+00:00"}
```

`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:

| Field | Type | Meaning |
| - | - | - |
| `id` | string | `evt_` and 32 hex characters. Unique per event, and the same on every attempt to deliver it |
| `object` | string | Always `event` |
| `type` | string | The event name |
| `created_at` | string | When the event was created, ISO 8601 with a `+00:00` offset. The same on every attempt |
| `workspace_id` | string | The workspace the event belongs to |
| `data` | object | The event's own fields (below) |

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](https://docs.sempera.org/changelog.md)). The examples below are indented for reading.

### Event data

A field marked `always` 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. `provider` is `sempera`.

A call is announced once, whichever road sees it first. Both roads share one record of what was announced, keyed on every identity a call has (its `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.

| Field | Type | Present | Meaning |
| - | - | - | - |
| `call_id` | string | always | The call. `GET /v1/calls/{call_id}` reads it (an interpreted call: `GET /v1/sessions/{call_id}`) |
| `agent_id` | string | always | The agent that took the call |
| `provider` | string | always | The provider label the carrier's post-call report named (`voice_provider` when it named none), or `sempera` when Sempera's own call service answered the call |
| `duration_s` | number or null | always | Call length in seconds; null when it is not known |
| `recording_stored` | boolean | always | Whether the call's recording was stored |
| `logs` | array | only when the workspace turned on "Include logs in webhook payload" (Settings, Organization) | The per-turn log; each turn is `{"index": number, "caller": string or null, "say": string or null}` |
| `metadata` | object | only when the agent's published version has metadata | The agent's own metadata (Settings, Metadata), such as the case ID your system matches on |

```json theme={null}
{
  "id": "evt_0f3a9b7c1d2e4f5a8b6c7d8e9f001122",
  "object": "event",
  "type": "call.completed",
  "created_at": "2026-09-28T14:05:31.204118+00:00",
  "workspace_id": "11111111-1111-4111-8111-111111111111",
  "data": {
    "call_id": "call-0a1b2c3d-0000-4000-8000-000000000001",
    "agent_id": "northwind-barbers",
    "provider": "voice_provider",
    "duration_s": 150.0,
    "recording_stored": true,
    "metadata": {"case_id": "A-17"}
  }
}
```

#### `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](https://docs.sempera.org/dispositions.md)); 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`.

| Field | Type | Present | Meaning |
| - | - | - | - |
| `agent_id` | string | always | The agent |
| `call_id` | string | always | The call |
| `version_number` | number or null | always | The version of the outcome definitions that read the call; null when the agent defines no outcomes |
| `outcomes` | array | always | One row per outcome, each `{"key", "name", "type", "status", "value", "quote"}`: `type` is `boolean`, `number`, `choice` or `text`; `status` is `found`, `not_found` or `unavailable`; `quote` is the sentence from the call that supports a found `value`, otherwise null. Empty when only a disposition asked for the event, or after the call's transcript was deleted |
| `metadata` | object | only when the agent's published version has metadata | The agent's own metadata |
| `dispositions` | array | only when a published disposition ran on the call | One entry per disposition: `disposition_id`, `key`, `name`, `version_id`, `version_number`, `status` (`complete`, `partial`, `failed` or `not_evaluated`), `values` (each `key`, `label`, `state`, `value`, `rationale`, and `error` when it failed), `payload` and `completed_at` |

```json theme={null}
{
  "id": "evt_5e6f7a8b9c0d4e1f8a2b3c4d5e6f7081",
  "object": "event",
  "type": "call.outcomes",
  "created_at": "2026-09-28T14:06:02.518774+00:00",
  "workspace_id": "11111111-1111-4111-8111-111111111111",
  "data": {
    "agent_id": "northwind-barbers",
    "call_id": "call-0a1b2c3d-0000-4000-8000-000000000001",
    "version_number": 2,
    "outcomes": [
      {"key": "booked", "name": "Booked an appointment", "type": "boolean", "status": "found", "value": true, "quote": "Done, you are booked."},
      {"key": "party", "name": "Party size", "type": "number", "status": "not_found", "value": null, "quote": null}
    ]
  }
}
```

A disposition an outage left unfinished is finished by a later pass, and the event is then sent again for the same `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.

| Field | Type | Present | Meaning |
| - | - | - | - |
| `batch_id` | string | always | The batch |
| `state` | string | always | The batch's current state |
| `summary` | object | always | Recipient counts by state |

```json theme={null}
{
  "id": "evt_00000000000000000000000000000002",
  "object": "event",
  "type": "batch.updated",
  "created_at": "2026-09-28T14:06:02.518774+00:00",
  "workspace_id": "11111111-1111-4111-8111-111111111111",
  "data": {"batch_id": "batch-review", "state": "done", "summary": {"done": 1}}
}
```

#### `agent.published`

| Field | Type | Present | Meaning |
| - | - | - | - |
| `agent_id` | string | always | The agent |
| `version` | object | always | The version just published, the same `agent_version` object `GET /v1/agents/{id}/versions` lists: `id`, `object` (`agent_version`), `agent_id`, `version_number` (integer), `created_at`, `spec_hash` and `compiled_artifact_id` |

```json theme={null}
{
  "id": "evt_a1b2c3d4e5f60718293a4b5c6d7e8f90",
  "object": "event",
  "type": "agent.published",
  "created_at": "2026-09-28T13:40:12.003211+00:00",
  "workspace_id": "11111111-1111-4111-8111-111111111111",
  "data": {
    "agent_id": "northwind-barbers",
    "version": {
      "id": "av-000000000000000000000001",
      "object": "agent_version",
      "agent_id": "northwind-barbers",
      "version_number": 3,
      "created_at": "2026-09-28T13:40:11.912345Z",
      "spec_hash": "0000000000000000000000000000000000000000000000000000000000000000",
      "compiled_artifact_id": "ca-000000000000000000000001"
    }
  }
}
```

#### `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.

| Field | Type | Present | Meaning |
| - | - | - | - |
| `test_id` | string | always | The run |
| `agent_id` | string | always | The agent under test |
| `status` | string | always | `completed`, or `failed` when the run itself crashed |
| `scope` | string | always | `full` or `scoped`; only a full run writes release evidence |
| `outcome` | string or null | always | The verdict; null on a failed run |
| `scenarios_scored` | number or null | always | Scenarios scored; null on a failed run |
| `violations` | number or null | always | Violations found; null on a failed run |
| `evidence` | boolean | always | Whether the run wrote release evidence |
| `started_at` | string | always | When the run started |
| `finished_at` | string | always | When it ended |

```json theme={null}
{
  "id": "evt_3c4d5e6f708192a3b4c5d6e7f8091a2b",
  "object": "event",
  "type": "test.completed",
  "created_at": "2026-09-28T10:02:00.410902+00:00",
  "workspace_id": "11111111-1111-4111-8111-111111111111",
  "data": {
    "test_id": "job-0123456789ab",
    "agent_id": "northwind-barbers",
    "status": "completed",
    "scope": "full",
    "outcome": "pass",
    "scenarios_scored": 12,
    "violations": 0,
    "evidence": true,
    "started_at": "2026-09-28T10:00:00Z",
    "finished_at": "2026-09-28T10:02:00Z"
  }
}
```

#### `metric_alert.triggered`

Sent to the endpoint an alert rule names, when the rule fires, worsens or recovers ([Metric alerts](https://docs.sempera.org/alerts.md)). 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.

| Field | Type | Present | Meaning |
| - | - | - | - |
| `rule_id` | string | always | The alert rule |
| `rule_name` | string | always | Its name |
| `metric` | string | always | The metric key the rule measures |
| `kind` | string | always | `fired`, `worsened` or `recovered` |
| `subject` | string | always | A one-line summary: `Alert: <rule name>`, or `Cleared: <rule name>` on a recovery |
| `message` | string | always | The message body, in plain sentences; its lines are joined with a newline |
| `severity` | string | in practice on every alert; treat as optional | `info`, `warning` or `critical`; on a recovery, the severity the rule was at before it cleared |
| `value_pct` | number | in practice on every alert; treat as optional | The share of calls measured, in percent |
| `threshold_pct` | number | on `fired` and `worsened`; absent on `recovered` | The threshold crossed, in percent |

```json theme={null}
{
  "id": "evt_9a8b7c6d5e4f30211a2b3c4d5e6f7a8b",
  "object": "event",
  "type": "metric_alert.triggered",
  "created_at": "2026-09-28T15:00:00.120004+00:00",
  "workspace_id": "11111111-1111-4111-8111-111111111111",
  "data": {
    "rule_id": "33333333-3333-4333-8333-333333333333",
    "rule_name": "Failed calls",
    "metric": "failed_calls",
    "kind": "fired",
    "subject": "Alert: Failed calls",
    "message": "Failed calls (Warning): Failed calls is 100% (3 of 3) over the last 60 minutes, at or above 50%.\nOpen Alerts in the Sempera App to see the rule and its trigger log.",
    "severity": "warning",
    "value_pct": 100.0,
    "threshold_pct": 50.0
  }
}
```

#### `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.

| Field | Type | Present | Meaning |
| - | - | - | - |
| `endpoint_id` | string | always | The endpoint tested |
| `actor` | string | always | Who asked for the test |

```json theme={null}
{
  "id": "evt_0b1c2d3e4f5061728394a5b6c7d8e9f0",
  "object": "event",
  "type": "webhook.test",
  "created_at": "2026-09-28T12:00:00.000000+00:00",
  "workspace_id": "11111111-1111-4111-8111-111111111111",
  "data": {
    "endpoint_id": "22222222-2222-4222-8222-222222222222",
    "actor": "key:k-owner-a"
  }
}
```

## Signing and verifying

Every delivery carries these headers:

| Header | Meaning |
| - | - |
| `X-Sempera-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>` |
| `X-Sempera-Event-Id` | The event's `id`. Unique per event and the same on every attempt |
| `X-Sempera-Event` | The event type |
| `Content-Type` | `application/json` |
| `User-Agent` | `Sempera-Webhooks/1` |

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

1. Take the raw request body bytes, exactly as received, before parsing.
2. Parse `t` and `v1` from the header.
3. Reject if `t` is more than 300 seconds from your clock, in either direction.
4. Compute `HMAC_SHA256(secret, "<t>." + raw_body)` as lowercase hex.
5. Compare with `v1` in constant time.
6. Drop an `X-Sempera-Event-Id` you have already processed.

```python theme={null}
import hmac, hashlib, time

def verify(secret: str, body: bytes, header: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts["t"])
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])
```

A known answer, so you can check your own implementation in any language:

```text theme={null}
secret = whsec_vector_0123456789
t      = 1790000000
body   = {"created_at":"2026-09-28T12:00:00+00:00","data":{"actor":"vector","endpoint_id":"22222222-2222-4222-8222-222222222222"},"id":"evt_00000000000000000000000000000001","object":"event","type":"webhook.test","workspace_id":"11111111-1111-4111-8111-111111111111"}
X-Sempera-Signature = t=1790000000,v1=1f4226699ca9fa61b71fbb57dbd2f57efa6788488437ee2e411ea4e3002be3c1
```

### 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](https://docs.sempera.org/widgets.md), 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 answers `2xx` 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:

| Field | Meaning |
| - | - |
| `id` | The log row |
| `object` | `webhook_delivery` |
| `event_id` | The event's `id`; all attempts of one event share it |
| `event_type` | The event name |
| `attempt` | 1 for the first attempt, then 2 and 3 |
| `status` | `delivered` or `failed` |
| `response_code` | Your endpoint's HTTP status; null when there was no answer |
| `error` | A plain sentence for a failed attempt; empty when delivered |
| `sent_at`, `created_at` | When the attempt was made and recorded |

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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.