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, 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
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-Deliveryis 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-Timestampis when this attempt was sent, in Unix seconds.X-LineHQ-Signatureproves 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.
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);
}
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_secondsis the whole call on LineHQ;call.talk_secondsis the part someone answered and talked for.answersays who answered, when LineHQ can tell: a staff label, a receptionist department, or a device.account.nameis the business name, for display. The signing secret is what identifies the account.
call.ended
{
"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.
{
"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
{
"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
{
"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.
This page as Markdown, for AI tools: https://linehq.co.uk/developers/webhooks.md