Hotline

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, .local hosts, 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:

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:

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.