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

# Call webhooks

> Get a signed POST when a call starts, and the full transcript when it ends.

Register a URL once and LumisReach POSTs to it as calls happen on your numbers:

* **`call.started`** when a call begins: who is calling (or who you called), which of your numbers, and
  their contact record.
* **`call.ended`** once the call is over and its transcript is saved: the same details plus how long it
  lasted, why it ended, and the **full transcript**, as plain text and turn by turn.

Covers inbound and outbound AI calls on every number on your account, whichever carrier it runs on.

## 1. Register your endpoint

```bash theme={null}
curl -X POST "https://api.lumisreach.com/api/v1/voice/calls/webhooks" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/lumisreach/calls", "events": ["call.started", "call.ended"] }'
```

| Field             | Meaning                                                                                    |
| ----------------- | ------------------------------------------------------------------------------------------ |
| `url`             | Public HTTPS endpoint to POST events to.                                                   |
| `events`          | `call.started`, `call.ended`, or both. Leave it out to get both.                           |
| `phone_number_id` | Only calls on this number (id from `GET /v1/phone-numbers`). Leave it out for all of them. |
| `description`     | A label for your own reference.                                                            |

The response includes a `secret` (`whsec_…`). **It is shown only once**: store it now, you need it to
verify deliveries. Lost it? Delete the webhook and create a new one.

A workspace can have up to 25 call webhooks. `GET /v1/voice/calls/webhooks` lists them and
`DELETE /v1/voice/calls/webhooks/{id}` removes one.

## 2. Receive `call.started`

```http theme={null}
POST /lumisreach/calls HTTP/1.1
Content-Type: application/json
X-LumisReach-Event: call.started
X-LumisReach-Event-Id: evt_3b8e…_started
X-LumisReach-Signature-256: 5d41402abc4b2a76b9719d911017c592…
```

```json theme={null}
{
  "id": "evt_3b8e61f0-…_started",
  "type": "call.started",
  "created_at": "2026-09-25T17:00:00.412Z",
  "test": false,
  "data": {
    "call": {
      "id": "3b8e61f0-…",
      "direction": "inbound",
      "status": "RINGING",
      "from_number": "+14155550123",
      "to_number": "+14157279482",
      "phone_number_id": 12,
      "started_at": null,
      "ended_at": null,
      "duration_seconds": null
    },
    "contact": {
      "id": "5f25da85-…",
      "name": "Jane Doe",
      "phone_number": "+14155550123",
      "email": "jane@example.com"
    }
  }
}
```

`contact` is the other party's contact record (the caller on an inbound call, the person you called on
an outbound one), or `null` if you don't have one for that number yet.

## 3. Receive `call.ended`, with the transcript

```json theme={null}
{
  "id": "evt_3b8e61f0-…_ended",
  "type": "call.ended",
  "created_at": "2026-09-25T17:01:36.020Z",
  "test": false,
  "data": {
    "call": {
      "id": "3b8e61f0-…",
      "direction": "inbound",
      "status": "COMPLETED",
      "from_number": "+14155550123",
      "to_number": "+14157279482",
      "phone_number_id": 12,
      "started_at": "2026-09-25T17:00:01.118Z",
      "ended_at": "2026-09-25T17:01:35.204Z",
      "duration_seconds": 94,
      "end_reason": "customer-ended-call"
    },
    "contact": {
      "id": "5f25da85-…",
      "name": "Jane Doe",
      "phone_number": "+14155550123",
      "email": "jane@example.com"
    },
    "transcript": "AI: Thanks for calling, how can I help?\nUser: Is Thursday still good for my appointment?\nAI: Yes, you're booked for Thursday at 2pm.",
    "transcript_turns": [
      { "speaker": "agent", "text": "Thanks for calling, how can I help?", "seconds_from_start": 0.8 },
      {
        "speaker": "person",
        "text": "Is Thursday still good for my appointment?",
        "seconds_from_start": 3.1
      },
      { "speaker": "agent", "text": "Yes, you're booked for Thursday at 2pm.", "seconds_from_start": 6.4 }
    ]
  }
}
```

| Field                   | Meaning                                                                                                                                                                    |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `data.call.id`          | The call's id. `GET /v1/history/calls/{id}` returns the same call, with its summary, tags and recording.                                                                   |
| `data.call.end_reason`  | Why the call ended, as reported by the voice stack.                                                                                                                        |
| `data.transcript`       | The whole conversation as plain text. `null` if transcription is turned off for the number.                                                                                |
| `data.transcript_turns` | The same conversation, one entry per turn. `speaker` is `agent` (your AI) or `person` (the other party). `seconds_from_start` is `null` where the stack doesn't report it. |
| `id`                    | Unique per call and event. Use it to ignore a duplicate if a retry arrives after you already handled it.                                                                   |

Call fields have the same names as in [`GET /v1/history/calls`](/api-reference/history), so you can
store a `call.ended` payload and a history item the same way.

`call.ended` does not include the recording: it is usually still being saved when the call ends. Fetch
it later from `GET /v1/history/calls/{id}/recording`. The AI summary and tags are also filled in shortly
after the call; read them from `GET /v1/history/calls/{id}`.

Respond with any `2xx` within 10 seconds.

## 4. Verify the signature

`X-LumisReach-Signature-256` is the hex HMAC-SHA256 of the **raw request body**, keyed with your
webhook `secret`. It is the same scheme every LumisReach webhook uses, including
[inbound SMS webhooks](/api-reference/sms/webhooks).

```ts theme={null}
import { createHmac, timingSafeEqual } from "crypto";

export function isFromLumisReach(rawBody: string, signature: string, secret: string): boolean {
  const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
  return (
    signature.length === expected.length && timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
  );
}
```

Compute it over the body exactly as received, before any JSON parsing.

## Retries

The first delivery is made right away. If your server errors, times out, or returns a non-`2xx`
(redirects are not followed), it is retried 3 more times: after 1, 5 and 30 minutes. Every attempt is
recorded in the webhook delivery log in your dashboard, where webhooks you register here also appear.

## Test it

`POST /v1/voice/calls/webhooks/{id}/test` sends a sample, correctly signed `call.ended` event (or
`call.started`, if that is the only event the webhook listens for) with `"test": true`, and tells you
how your server responded.
