# Seed Webhook API

Seed can POST each transcript to a URL you configure. The public app name changes from Dictate in version 2.0.0; the wire contract and `Dictate/<build>` User-Agent stay unchanged. This doc is the complete reference for receiving those webhooks.

## Setup

In the Seed app, go to **Settings → Advanced → Webhook → Destination** and enter your endpoint URL, then toggle it on.

> 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. Cloud metadata endpoints are blocked.

## Automation Platforms

These platforms accept Seed's webhook out of the box : create a webhook trigger, copy the URL, paste it into Seed:

- **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`           | `Dictate/<build>` from the iPhone,. Informational only: never authenticate on it. |

### Which device sends it

Usually the iPhone. The Apple Watch records and hands the audio to the iPhone, which transcribes it and sends the webhook; there is no standalone delivery from the Watch. Exactly one request is sent per recording. Deduplicate on `recording_id` anyway if a duplicate would be expensive for you.

### Body

Every delivery carries the same object. `mode` is always `solo`, regardless of duration.

A solo recording:

```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": "solo",
  "language": "en",
  "segments": [
    { "speaker": null, "start": 0.0, "end": 4.2, "text": "Just had a great idea for the landing page..." }
  ]
}
```


| 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. |
| `device_id`     | `string` (UUID)     | Stable per-device identifier. Not tied to any account — useful for distinguishing multiple devices. |
| `mode`          | `string`            | Always `"solo"`. |
| `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. Seed 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

Each recording is one POST. Size your handler to accept bodies up to 8 MB.

Segments retain on-device phrase timing. New recordings do not assign speaker numbers.

## Response

Return any **2xx** status code to acknowledge receipt. Each request waits up to **10 seconds** for that response. The response body is ignored: Seed does not read or store it.

## Retries

If your endpoint returns a non-2xx status or the request fails, Seed makes **3 attempts in total** (the original request plus 2 retries), with a delay between attempts:

| Attempt   | Delay before it |
| --------- | ---------------- |
| 2nd       | 30 seconds        |
| 3rd       | 120 seconds       |

After the 3rd attempt fails, 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. Verify it before you do anything else with the body, over the raw bytes and with a constant-time comparison. If you regenerate the signing secret in Settings, update your server before the next transcription completes.

A complete receiver: save it as `receiver.ts`, run `DICTATE_WEBHOOK_SECRET=<your secret> bun run receiver.ts`, and point Seed at `http://<your-host>:3000/webhook`. No dependencies, no framework.

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

const secret = process.env.DICTATE_WEBHOOK_SECRET;
if (!secret) {
  console.error("DICTATE_WEBHOOK_SECRET is not set");
  process.exit(1);
}

function signatureMatches(raw: Buffer, header: string | null): boolean {
  const expected = Buffer.from("sha256=" + createHmac("sha256", secret!).update(raw).digest("hex"));
  const got = Buffer.from(header ?? "");
  return got.length === expected.length && timingSafeEqual(got, expected);
}

async function handle(payload: any) {
  // Slow work goes here, after the response: storage, an LLM call, whatever.
  console.log(`${payload.mode}: ${payload.transcription}`);
  for (const s of payload.segments) {
    console.log(`  [${s.start}-${s.end}] ${s.speaker ?? "unlabelled"}: ${s.text}`);
  }
}

const server = Bun.serve({
  port: 3000,
  routes: {
    "/webhook": {
      POST: async (req) => {
        // Raw bytes before any parse: a re-encoded body will not match.
        const raw = Buffer.from(await req.arrayBuffer());
        if (!signatureMatches(raw, req.headers.get("x-webhook-signature"))) {
          return Response.json({ error: "bad signature" }, { status: 401 });
        }

        const payload = JSON.parse(raw.toString());
        void handle(payload); // not awaited: acknowledge first, work after
        return Response.json({ ok: true });
      },
    },
  },
});

console.log(`listening on ${server.url}webhook`);
```

## Test Webhooks

Tap **Settings → Advanced → Webhook → Destination → Send Test Request** 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 Seed.",
  "device_id": "7a2b3c4d-...",
  "mode": "solo",
  "language": null,
  "segments": [],
  "test": true
}
```

## Timing

Webhooks fire after transcription completes, not after recording. Transcription timing depends on audio length.

The webhook queue is independent from transcription. A slow or failing webhook never blocks the UI or other recordings.
