Hotline Webhook API
Hotline can POST each transcript to a URL you configure. This doc is the complete reference for receiving those webhooks.
Setup
In the Hotline app, go to Settings → Webhook and toggle it on. Enter your endpoint URL.
Public URLs must use HTTPS. Plain
http://is accepted only for localhost,.localhosts, and private network addresses (10/8, 172.16/12, 192.168/16, and Tailscale’s 100.64/10), so a personal agent on your own Wi-Fi works without a certificate. The same rule applies to a custom transcription endpoint. Cloud metadata endpoints are blocked.
Automation Platforms
These platforms accept Hotline’s webhook out of the box — create a webhook trigger, copy the URL, paste it into Hotline:
- Zapier — Use the “Webhooks by Zapier” Catch Hook trigger. Setup guide
- Make — Use the Custom Webhook module. Setup guide
- Airtable — Use the “When webhook received” automation trigger. Setup guide
- n8n — Use the Webhook node. Setup guide
Notion and IFTTT don’t accept incoming webhooks directly — use Zapier or Make as a bridge.
Custom Endpoint Reference
If you’re building your own endpoint, the rest of this document covers the payload format, signature verification, and retry behavior.
Request
Method: POST
Content-Type: application/json
Headers
| Header | Description |
|---|---|
Content-Type |
application/json |
X-Webhook-Signature |
HMAC-SHA256 digest of the raw request body, keyed with your signing secret. |
User-Agent |
Hotline/<build> from an iPhone or iPad, HotlineWatchApp/<build> when the Apple Watch delivered it directly, with ;bg appended when the delivery ran on a background transfer session. Background transfers are behind a feature flag that is off in this build, so no delivery carries ;bg today. Informational only: never authenticate on it. |
Which device sends it
Usually the iPhone. When you record on the Apple Watch with no iPhone in range, the Watch transcribes and POSTs the webhook itself, with the same body, the same signature, and the same retry schedule — the only difference is the User-Agent. Exactly one request is sent per recording: the phone files the Watch’s recording into its history as already delivered and never re-sends it. Deduplicate on recording_id anyway if a duplicate would be expensive for you.
Body
Every delivery carries the same object, whether it is a five-second message or a two-hour meeting. Branch on mode; do not infer intent from duration.
A message:
{
"recording_id": "550e8400-e29b-41d4-a716-446655440000",
"created_at": "2026-03-17T13:19:00Z",
"duration": 138.5,
"transcription": "Just had a great idea for the landing page...",
"device_id": "7a2b3c4d-5e6f-7890-abcd-ef1234567890",
"mode": "message",
"language": "en",
"segments": [
{ "speaker": null, "start": 0.0, "end": 4.2, "text": "Just had a great idea for the landing page..." }
]
}
A meeting, with speaker labels:
{
"recording_id": "6b1f0c22-9a3d-4f18-8c77-2e5b9a41d003",
"created_at": "2026-03-17T14:00:00Z",
"duration": 2415.0,
"transcription": "Speaker 1: Let us start with the pricing page.\n\nSpeaker 2: I pushed the new copy this morning.",
"device_id": "7a2b3c4d-5e6f-7890-abcd-ef1234567890",
"mode": "meeting",
"language": "en",
"segments": [
{ "speaker": 0, "start": 0.4, "end": 3.1, "text": "Let us start with the pricing page." },
{ "speaker": 1, "start": 3.4, "end": 6.9, "text": "I pushed the new copy this morning." }
]
}
| Field | Type | Description |
|---|---|---|
recording_id |
string (UUID v4) |
Unique identifier for this recording. |
created_at |
string (ISO 8601) |
When the recording was created. |
duration |
number |
Recording duration in seconds. |
transcription |
string |
Full transcript text. Empty string if transcription produced no output. For a meeting it is speaker-labelled paragraphs (“Speaker 1: …”), separated by blank lines. |
device_id |
string (UUID) |
Stable per-device identifier. Not tied to any account — useful for distinguishing multiple devices. |
mode |
string |
"message" or "meeting". A meeting is a recording the user marked as one while recording it. |
language |
string or null |
ISO 639-1 code the provider reported, or null when it reported none. |
segments |
array |
Timed stretches of speech, in time order. Empty when the provider returned no timing. Each entry has speaker (integer, 0-based, or null when unlabelled), start and end in seconds, and text. |
mode, language and segments were added after the first release. They are additive: a receiver written against the earlier body, which had the first five fields, keeps working with no change. Hotline only ever adds keys to this object; it does not rename or retype existing ones. The signature is computed over the whole body, as before.
Size
A meeting is one POST regardless of length, retried on the same schedule as a message. A two-hour meeting transcript with its segments is on the order of 1 MB; size your handler to accept bodies up to 8 MB.
Speaker labels
Speaker labels come from the transcription provider, so they depend on which model is selected:
- Scribe v2 (ElevenLabs) labels the whole file in one pass, so speaker numbers are consistent from the first minute to the last. This is the recommended model for meetings.
- GPT-Transcribe (OpenAI) uses
gpt-4o-transcribe-diarizefor meetings. Requests are capped at 25 MB, so a recording over 20 minutes is split into chunks and each chunk is labelled on its own. Speaker numbers are therefore not linked across chunks: Speaker 1 in the second chunk is not necessarily Speaker 1 in the first. - Groq Whisper and Custom server return no speaker labels. Meeting mode is not offered for them, so their recordings are always
"message"and theirsegmentscarry"speaker": null.
Response
Return any 2xx status code within 10 seconds to acknowledge receipt. The response body is ignored — Hotline does not read or store it.
Retries
If your endpoint returns a non-2xx status or the request fails, Hotline retries up to 3 times with exponential backoff:
| Attempt | Delay |
|---|---|
| 1st retry | ~5 seconds |
| 2nd retry | ~30 seconds |
| 3rd retry | ~120 seconds |
After 3 failed retries, the webhook is marked as failed. The user can manually retry from the recording detail screen. Pending webhooks are persisted to disk so nothing is lost if the app is closed.
Verifying the Signature
Every request includes an X-Webhook-Signature header — an HMAC-SHA256 digest of the raw request body, keyed with your signing secret. Always verify this before processing to confirm the request came from Hotline. Use constant-time comparison to prevent timing attacks.
const crypto = require("crypto");
function verifySignature(rawBody, signatureHeader, secret) {
const hmac = crypto.createHmac("sha256", secret);
hmac.update(rawBody);
const expected = "sha256=" + hmac.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signatureHeader)
);
}
If you regenerate the signing secret in Settings, update your server before the next transcription completes.
Full Express Server Example
const express = require("express");
const crypto = require("crypto");
const app = express();
const WEBHOOK_SECRET = process.env.HOTLINE_WEBHOOK_SECRET;
app.post(
"/webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.headers["x-webhook-signature"];
if (!signature) {
return res.status(401).json({ error: "Missing signature" });
}
const hmac = crypto.createHmac("sha256", WEBHOOK_SECRET);
hmac.update(req.body);
const expected = "sha256=" + hmac.digest("hex");
if (
!crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
)
) {
return res.status(401).json({ error: "Invalid signature" });
}
const payload = JSON.parse(req.body);
console.log("Transcription:", payload.transcription);
res.status(200).json({ ok: true });
}
);
app.listen(3000, () => console.log("Listening on :3000"));
Test Webhooks
Tap Settings → Send Test Webhook to fire a test payload at your endpoint. The test payload is identical to a real one, with two differences: test is true, and recording_id is prefixed with test-. Filter on either to skip test payloads in production.
{
"recording_id": "test-550e8400-...",
"created_at": "2026-04-09T12:00:00Z",
"duration": 0,
"transcription": "This is a test webhook from Hotline.",
"device_id": "7a2b3c4d-...",
"mode": "message",
"language": null,
"segments": [],
"test": true
}
Timing
Webhooks fire after transcription completes — not after recording. Transcription timing depends on audio length and network conditions.
The webhook queue is independent from transcription. A slow or failing webhook never blocks the UI or other recordings.