# 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:

- **Zapier** — Use the "Webhooks by Zapier" Catch Hook trigger. [Setup guide](https://help.zapier.com/hc/en-us/articles/8496288690317-Trigger-Zaps-from-webhooks)
- **Make** — Use the Custom Webhook module. [Setup guide](https://www.make.com/en/help/tools/webhooks)
- **Airtable** — Use the "When webhook received" automation trigger. [Setup guide](https://support.airtable.com/docs/when-webhook-received-trigger)
- **n8n** — Use the Webhook node. [Setup guide](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook/)

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:

```json
{
  "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:

```json
{
  "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-diarize` for 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 their `segments` carry `"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.

```js
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

```js
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.

```json
{
  "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.
