# Webhooks

LineHQ posts an event to your HTTPS endpoint the moment something happens on a call, so your system can act without polling. Webhooks are part of the Integrations add-on. An account owner adds up to 5 endpoints on the Integrations page in the LineHQ portal, or [through the API](https://linehq.co.uk/developers/reference#webhooks), and chooses which events each receives.

## Events

| Event | Sent when |
| --- | --- |
| `call.ended` | Any call on the account ends, except blocked and emergency calls. |
| `voicemail.received` | A voicemail has been delivered to the account, once per voicemail. |
| `transcript.ready` | A recorded call (not a voicemail) has its transcript, and its AI summary if one could be made. Once per call, within a day of the transcript being made. |
| `test` | Someone pressed "Send test event", or called Send a test event. The body is an invented example with `"test": true`. |

## The request

```http
POST /your/path HTTP/1.1
Content-Type: application/json
User-Agent: LineHQ-Webhooks/1
X-LineHQ-Event: call.ended
X-LineHQ-Delivery: 2c9e7b14-5f3a-4d8e-b1c6-7a9f0e2d4b63
X-LineHQ-Timestamp: 1791966794
X-LineHQ-Signature: 1bd539b1083df86f8796447ee0b8f759cf54912bf315374b312484a8fb07812c
```

- `X-LineHQ-Delivery` is the event's id. It's the same on every retry and resend, so use it to ignore an event you've already handled.
- `X-LineHQ-Timestamp` is when this attempt was sent, in Unix seconds.
- `X-LineHQ-Signature` proves the request came from LineHQ (see below).

Answer with any `2xx` status within 10 seconds. Redirects aren't followed.

## Checking the signature

Each endpoint has a signing secret starting `whsec_`, shown once when the endpoint is added or its secret is replaced. The signature is the hex HMAC-SHA256 of the timestamp, a dot, and the raw request body, keyed with that secret. Check it before trusting a request: compare in constant time, and refuse timestamps more than five minutes old so a captured request can't be replayed.

```javascript
import crypto from "node:crypto";

// rawBody: the request body exactly as received, before any JSON parsing.
// headers: with lower-case names, as Node gives them.
function isFromLineHQ(rawBody, headers, secret) {
  const timestamp = String(headers["x-linehq-timestamp"] ?? "");
  const signature = Buffer.from(String(headers["x-linehq-signature"] ?? ""), "hex");
  const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
  const fresh = /^\d+$/.test(timestamp) && Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;

  return fresh && signature.length === expected.length && crypto.timingSafeEqual(signature, expected);
}
```

```python
import hashlib
import hmac
import time


# headers: a case-insensitive mapping, as Flask and Django give you.
def is_from_linehq(raw_body: bytes, headers, secret: str) -> bool:
    timestamp = headers.get("X-LineHQ-Timestamp", "")
    signature = headers.get("X-LineHQ-Signature", "")
    expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    fresh = timestamp.isdigit() and abs(time.time() - int(timestamp)) <= 300

    return fresh and hmac.compare_digest(signature.encode(), expected.encode())
```

## Retries

A delivery that doesn't get a `2xx` in time is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 10 hours, then marked failed. An endpoint that has failed continuously for 24 hours is switched off, and the account's owners are emailed once. A failed or cancelled delivery can be sent again with the same event id: press Resend in the portal, or call Resend a delivery.

## The payloads

Every event has the same shape. `card` is the AI summary: it's `null` until a summary exists, so it's usually `null` on `call.ended`, which is normally sent before the summary is made. Webhooks never carry recordings or transcript text: fetch those with `links.transcript_api` and `links.recording_api`, using an API key.

- `call.duration_seconds` is the whole call on LineHQ; `call.talk_seconds` is the part someone answered and talked for.
- `answer` says who answered, when LineHQ can tell: a staff label, a receptionist department, or a device.
- `account.name` is the business name, for display. The signing secret is what identifies the account.

### `call.ended`

```json
{
    "event": "call.ended",
    "id": "2c9e7b14-5f3a-4d8e-b1c6-7a9f0e2d4b63",
    "created_at": "2026-10-14T09:33:14+01:00",
    "account": {
        "name": "Acme Plumbing"
    },
    "call": {
        "id": "Qm4Tz8Kc1Lp6Wr3Nx9Bv2Hd7Fs5Jy0Ga",
        "direction": "inbound",
        "from": "+447700900123",
        "to": "+442079460000",
        "started_at": "2026-10-14T09:30:12+01:00",
        "ended_at": "2026-10-14T09:33:12+01:00",
        "duration_seconds": 180,
        "talk_seconds": 170,
        "disposition": "answered"
    },
    "number": {
        "e164": "+442079460000",
        "label": "Main line"
    },
    "answer": {
        "staff_member": "Front desk",
        "department": "Appointments",
        "target_type": "phone",
        "target_label": "Front desk mobile",
        "answered": true
    },
    "card": null,
    "links": {
        "dashboard": "https://app.linehq.co.uk/calls/Qm4Tz8Kc1Lp6Wr3Nx9Bv2Hd7Fs5Jy0Ga",
        "transcript_api": "https://app.linehq.co.uk/api/v1/recordings/Rk3Lm8Nq2Tz6Vb1Xc5Wd9Hs4Jf0Pg7Ya/transcript",
        "recording_api": "https://app.linehq.co.uk/api/v1/recordings/Rk3Lm8Nq2Tz6Vb1Xc5Wd9Hs4Jf0Pg7Ya/stream-url"
    }
}
```

### `voicemail.received`

A voicemail also carries `links.shared_page`: a page where anyone with the link can hear the voicemail and read its transcript, so you can pass it on. Treat it like a password. It stops working when the number's link lifetime ends, when the owner closes pages already sent, or when the recording is deleted.

```json
{
    "event": "voicemail.received",
    "id": "2c9e7b14-5f3a-4d8e-b1c6-7a9f0e2d4b63",
    "created_at": "2026-10-14T09:33:14+01:00",
    "account": {
        "name": "Acme Plumbing"
    },
    "call": {
        "id": "Qm4Tz8Kc1Lp6Wr3Nx9Bv2Hd7Fs5Jy0Ga",
        "direction": "inbound",
        "from": "+447700900123",
        "to": "+442079460000",
        "started_at": "2026-10-14T09:30:12+01:00",
        "ended_at": "2026-10-14T09:33:12+01:00",
        "duration_seconds": 180,
        "talk_seconds": 0,
        "disposition": "voicemail"
    },
    "number": {
        "e164": "+442079460000",
        "label": "Main line"
    },
    "answer": {
        "staff_member": null,
        "department": null,
        "target_type": null,
        "target_label": null,
        "answered": false
    },
    "card": {
        "subject": "Call: dave wants a call back about the boiler today",
        "gist": "Dave wants a call back about the boiler today.",
        "mood": "neutral",
        "urgency": "Urgent",
        "to_dos": [
            "Call Dave back today"
        ],
        "callback_number": "+447700900123"
    },
    "links": {
        "dashboard": "https://app.linehq.co.uk/calls/Qm4Tz8Kc1Lp6Wr3Nx9Bv2Hd7Fs5Jy0Ga",
        "transcript_api": "https://app.linehq.co.uk/api/v1/recordings/Rk3Lm8Nq2Tz6Vb1Xc5Wd9Hs4Jf0Pg7Ya/transcript",
        "recording_api": "https://app.linehq.co.uk/api/v1/recordings/Rk3Lm8Nq2Tz6Vb1Xc5Wd9Hs4Jf0Pg7Ya/stream-url",
        "shared_page": "https://app.linehq.co.uk/voicemail/Rk3Lm8Nq2Tz6Vb1Xc5Wd9Hs4Jf0Pg7Ya?expires=1792571594&issued=1791966794&signature=1bd539b1083df86f8796447ee0b8f759cf54912bf315374b312484a8fb07812c"
    }
}
```

### `transcript.ready`

```json
{
    "event": "transcript.ready",
    "id": "2c9e7b14-5f3a-4d8e-b1c6-7a9f0e2d4b63",
    "created_at": "2026-10-14T09:33:14+01:00",
    "account": {
        "name": "Acme Plumbing"
    },
    "call": {
        "id": "Qm4Tz8Kc1Lp6Wr3Nx9Bv2Hd7Fs5Jy0Ga",
        "direction": "inbound",
        "from": "+447700900123",
        "to": "+442079460000",
        "started_at": "2026-10-14T09:30:12+01:00",
        "ended_at": "2026-10-14T09:33:12+01:00",
        "duration_seconds": 180,
        "talk_seconds": 170,
        "disposition": "answered"
    },
    "number": {
        "e164": "+442079460000",
        "label": "Main line"
    },
    "answer": {
        "staff_member": "Front desk",
        "department": "Appointments",
        "target_type": "phone",
        "target_label": "Front desk mobile",
        "answered": true
    },
    "card": {
        "subject": "Call: dave wants a call back about the boiler today",
        "gist": "Dave wants a call back about the boiler today.",
        "mood": "neutral",
        "urgency": "Urgent",
        "to_dos": [
            "Call Dave back today"
        ],
        "callback_number": "+447700900123"
    },
    "links": {
        "dashboard": "https://app.linehq.co.uk/calls/Qm4Tz8Kc1Lp6Wr3Nx9Bv2Hd7Fs5Jy0Ga",
        "transcript_api": "https://app.linehq.co.uk/api/v1/recordings/Rk3Lm8Nq2Tz6Vb1Xc5Wd9Hs4Jf0Pg7Ya/transcript",
        "recording_api": "https://app.linehq.co.uk/api/v1/recordings/Rk3Lm8Nq2Tz6Vb1Xc5Wd9Hs4Jf0Pg7Ya/stream-url"
    }
}
```

### `test`

```json
{
    "event": "test",
    "id": "2c9e7b14-5f3a-4d8e-b1c6-7a9f0e2d4b63",
    "created_at": "2026-10-14T09:33:14+01:00",
    "test": true,
    "account": {
        "name": "Your business"
    },
    "call": {
        "id": "test",
        "direction": "inbound",
        "from": "+447700900123",
        "to": "+442079460000",
        "started_at": "2026-10-14T09:30:12+01:00",
        "ended_at": "2026-10-14T09:33:12+01:00",
        "duration_seconds": 180,
        "talk_seconds": 170,
        "disposition": "answered"
    },
    "number": {
        "e164": "+442079460000",
        "label": "Main line"
    },
    "answer": {
        "staff_member": "Front desk",
        "department": "Appointments",
        "target_type": "phone",
        "target_label": "Front desk mobile",
        "answered": true
    },
    "card": {
        "subject": "Call: dave wants a call back about the boiler today",
        "gist": "Dave wants a call back about the boiler today.",
        "mood": "neutral",
        "urgency": "Urgent",
        "to_dos": [
            "Call Dave back today"
        ],
        "callback_number": "+447700900123"
    },
    "links": {
        "dashboard": null,
        "transcript_api": null,
        "recording_api": null
    }
}
```

## Testing

Press "Send test event" on the Integrations page, or call Send a test event. Your endpoint receives a `test` event with an invented call and a full `card`, signed like any other delivery. It's handy for mapping fields in a tool such as Zapier: add a "Webhooks by Zapier" trigger, choose "Catch Hook", add the address Zapier gives you as a LineHQ endpoint, then send a test event.
