Seed

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:

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:

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

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.

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