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

# Webhooks

A call ends, we analyze it, and we POST what happened to your endpoint. That is the whole model: configure a **webhook connection** once per project, and every call reports in — inbound, web, API dials, and campaigns alike.

<Note>
  This page is about **event webhooks**: us telling your system a call finished. [Webhook tools](/voice-agents/tools/webhook-tools) are the other direction — the assistant calling your API mid-conversation and speaking the answer. Both arrive signed the same way, but you set them up separately. A **webhook** gets every call event and never a tool call. A **tool endpoint** gets every tool call and never an event.
</Note>

## The four events

| Event                                                                    | When it fires                                                                       | `data` carries        |
| ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------- | --------------------- |
| [`call.completed`](/voice-agents/webhooks/events#call-completed)         | Every call, once its post-call analysis runs — failed dials included                | `callId`, `sessionId` |
| [`call.graded`](/voice-agents/webhooks/events#call-graded)               | The call connected and a [scorecard](/voice-agents/assistants/scorecards) graded it | `callId`, `score`     |
| [`conversion.created`](/voice-agents/webhooks/events#conversion-created) | The call produced a conversion                                                      | `callId`              |
| [`callback.opened`](/voice-agents/webhooks/events#callback-opened)       | The call opened a callback                                                          | `callId`              |

Four things to know before you build on these:

* **`call.completed` does not mean the call connected.** A busy line and a wrong number fire it too. Read the session status for [`state`](/api-reference/sessions-%26-call-records/get-a-sessions-status#response-state) and [`failureReason`](/api-reference/sessions-%26-call-records/get-a-sessions-status#response-failure-reason) before treating it as a conversation.
* **No scorecard means no `call.graded`, ever.** Grading rides the scorecard: assistant-level for direct calls, campaign-level for campaign calls.
* **Emit order is fixed, arrival order is not.** Events go out as completed, graded, conversion, callback, but each one retries independently. Don't assume `call.completed` lands first.
* **`occurredAt` is when the analysis ran**, not when the call ended. For call timings, read the session's timestamps.

## What arrives

```json theme={null}
{
  "schemaVersion": "upliftai.call.v1",
  "eventType": "call.graded",
  "eventId": "3f1c8a02-9d44-4e1b-b0a7-2c5e6f8d1a90#call.graded",
  "assistantId": "01efae24-b353-4621-a85a-4a04cba97570",
  "occurredAt": "2026-08-21T12:00:00.000Z",
  "data": { "callId": "3f1c8a02-9d44-4e1b-b0a7-2c5e6f8d1a90", "score": 82 }
}
```

* **`eventId` is your idempotency key.** It is `callId#eventType`, and a retry can deliver the same event twice. Process once per `eventId`.
* **`campaignId` appears only on campaign calls.** On every other call the key is absent, not null.
* **`callId` and `sessionId` are the same string today.** Either is the id every session endpoint takes.
* **The payload is thin on purpose.** No transcript, no recording, no per-rule breakdown, no phone number. The event tells you when to look, and the [session detail](/api-reference/sessions-%26-call-records/get-a-calls-transcript-and-outcomes) is where you look.

Full payloads for all four events are on the [event reference](/voice-agents/webhooks/events).

## Subscribing

Add a webhook on the portal's [Webhooks page](https://upliftai.org/app/calling/webhooks): a **name**, the **HTTPS URL**, and a **signing secret**. **Generate** makes a strong secret for you. Store it on your server. Every delivery is signed with it.

* **Adding the webhook is the opt-in.** Every webhook in the project receives every event for every call. There is no per-event filter.
* **The URL must be HTTPS and publicly routable.** Private, loopback, and link-local addresses are rejected at create time.
* **To rotate the secret**, add a webhook with the new one, then delete the old one.

<Warning>
  Set the signing secret when you add the webhook. Without one, deliveries still go out, signed with an empty key, which is no signature at all.
</Warning>

## Verifying the signature

Every delivery carries one header:

```
x-uplift-ai-signature: t=1755772800,v1=6c0f0e8b1a…
```

`v1` is HMAC-SHA256 over `` `${t}.${body}` `` with your `signingSecret`, hex-encoded. `t` is unix seconds and sits inside the signed material, which is what makes replay detection work. Reject anything older than five minutes and compare in constant time:

```js theme={null}
import crypto from 'node:crypto'

function verify(rawBody, header, secret) {
  const m = /^t=(\d+),v1=([0-9a-f]+)$/.exec(header)
  if (!m) return false
  const [, t, sig] = m
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
  return expected.length === sig.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))
}
```

Sign over the **raw request body**, before any JSON parsing touches it.

One more POST to expect: the **Test** button on the Webhooks page sends `{"type": "ping", "connectionId": "…"}`, signed the same way but with **no envelope**. Handle it before you switch on `eventType`, and answer 2xx.

## Delivery and retries

* **Answer fast with any 2xx.** The response body is ignored. The deadline is 10 seconds, so queue the work rather than doing it inline.
* **Transient failures retry.** Timeouts, network errors, `408`, `429`, and any `5xx` are retried at 30 seconds, 5 minutes, and 30 minutes. Four attempts total.
* **Any other 4xx does not retry.** A `401` or `404` is a permanent failure and the delivery dies on the first attempt.
* **Dead deliveries land in Delivery failures** on the [Webhooks page](https://upliftai.org/app/calling/webhooks), with the status code, the reason, and the attempt count, kept for 90 days. There is no event for a failed delivery.

## Testing from localhost

We only call **public HTTPS** URLs. `localhost` and private addresses are rejected when you save the connection, so put a tunnel in front of your dev server:

```bash theme={null}
ngrok http 3000
```

The tunnel prints a public address like `https://a1b2c3d4.ngrok-free.app`. The `url` is that address plus your route. Add a webhook with it exactly as in [Subscribing](#subscribing), with the secret your local server checks. Nothing to wire after that. Every webhook connection receives every call event. Make a call, and `call.completed` lands a few seconds after hangup. For a faster first check, **Test** sends a signed ping with no call at all.

Every request shows up in ngrok's inspector at `http://127.0.0.1:4040`, headers and body included. Replay one from there while you fix the handler.

* **Free ngrok URLs change every restart.** Claim ngrok's free static domain, so the URL you saved keeps working.
* **The tunnel is public.** Keep the signature check on, even on your laptop.
