# Webhooks

Pigeon can send each recording to a URL you control.

## Build your receiver

Pick a host and copy the prompt to your coding agent, or start from the code. If your URL doesn't answer 200, Pigeon retries for up to 3 days.

### Cloudflare Workers

Workers take bodies up to 100 MB, so long memos with audio fit.

Prompt for your coding agent:

Build a Pigeon webhook receiver on Cloudflare Workers. The contract is at https://pigeon.newyorkai.org/webhooks.md. Check the bearer token against a secret named PIGEON_TOKEN, return 200, then save the transcript by recording_id and the audio to R2. Ask me what should happen with each transcript. Deploy with wrangler, then give me the URL to paste into Pigeon. Pigeon makes the secret. Don't ask me to paste it into this chat. Tell me to run `npx wrangler secret put PIGEON_TOKEN` in my own terminal and paste it at the prompt.

```js
export default {
  async fetch(req, env, ctx) {
    const auth = req.headers.get("authorization");
    if (auth !== `Bearer ${env.PIGEON_TOKEN}`) {
      return new Response("bad token", { status: 401 });
    }
    const form = await req.formData();
    // payload is a string part, not a file
    const memo = JSON.parse(form.get("payload"));
    if (memo.event === "test") return new Response("ok");
    const audio = form.get("audio"); // File or null
    // Answer first. Do the slow work after.
    ctx.waitUntil(save(memo, audio, env));
    return new Response("ok");
  },
};

async function save(memo, audio, env) {
  const id = memo.recording_id;
  if (audio) await env.AUDIO.put(`${id}.m4a`, audio);
  // Store memo.transcript by id, then run your agent
  // when memo.transcript_status is "complete".
}
```

### Vercel (transcript only)

Vercel rejects requests over 4.5 MB, about four minutes of audio. Leave **include audio** off in Pigeon. If it's on, a memo that's too big gets a 413 and Pigeon sends the transcript without the audio.

Prompt for your coding agent:

Build a Pigeon webhook receiver as a Vercel function at app/api/pigeon/route.js. The contract is at https://pigeon.newyorkai.org/webhooks.md. Check the bearer token against an environment variable named PIGEON_TOKEN, return 200, then store the transcript by recording_id in a database. I'll leave audio off in Pigeon. Ask me what should happen with each transcript. Deploy with the Vercel CLI, then give me the URL to paste into Pigeon. Pigeon makes the secret. Don't ask me to paste it into this chat. Tell me to run `vercel env add PIGEON_TOKEN production` in my own terminal, paste it at the prompt, then redeploy.

```js
import { waitUntil } from "@vercel/functions";

export async function POST(req) {
  const auth = req.headers.get("authorization");
  if (auth !== `Bearer ${process.env.PIGEON_TOKEN}`) {
    return new Response("bad token", { status: 401 });
  }
  const form = await req.formData();
  // payload is a string part, not a file
  const memo = JSON.parse(form.get("payload"));
  if (memo.event === "test") return new Response("ok");
  // Answer first. Do the slow work after.
  waitUntil(save(memo));
  return new Response("ok");
}

async function save(memo) {
  // Store memo.transcript by memo.recording_id, then
  // run your agent when transcript_status is "complete".
}
```

Anything that accepts a multipart POST works. Modal takes bodies up to 4 GiB. Val Town takes 100 MB, with a one minute limit per run on the free plan.

## Set up webhooks on your phone

1. Open settings and turn on **send to webhook**.
2. Paste your URL into **address**.
3. Pigeon fills in **secret** for you. Tap **copy secret** and save it as `PIGEON_TOKEN` on your host (the prompts above show how), or paste your own.
4. Turn on **include audio** if you want the recording too. It starts off.
5. Tap **send test**. You should see **test delivered**.
6. Tap **save**.

The secret goes out with every request so your server knows it's you. Pigeon makes a random one when the field is empty. Keep it. Without it, anyone who finds the URL can send to it.

Use the final URL. A redirect can turn the POST into a GET, and Pigeon says the address doesn't accept recordings.

Turning **send to webhook** off pauses sending. Recordings made while it's off aren't sent.

## Try a sample

To see one request before you have a server:

1. Open [webhook.site](https://webhook.site) and copy the URL.
2. In Pigeon, turn on **send to webhook**, paste the URL, clear **secret**, and tap **send test**.
3. The request shows up on that page.

webhook.site isn't our site, and it isn't yours. Anyone with the link can read what lands there, so don't send your real secret, a real memo, or anything sensitive.

## What you receive

POST to your URL, one per recording, as `multipart/form-data`.

| Header | Value |
|---|---|
| `Authorization` | `Bearer <secret>`. Left out when the secret is blank. |
| `X-Pigeon-Event` | `recording`, or `test` for send test |
| `X-Pigeon-Recording-Id` | Same as `recording_id` |
| `X-Pigeon-Version` | `1` |

| Part | Contents |
|---|---|
| `payload` | JSON, always sent. It's a text part, not a file. |
| `audio` | The `.m4a` recording (`audio/mp4`), named `<recording_id>.m4a`. Sent when include audio is on and the file is still on the phone. About 1 MB a minute. |

The `payload` part:

```json
{
  "version": 1,
  "event": "recording",
  "recording_id": "3F2A9C1E-...",
  "recorded_at": "2026-09-30T17:56:06Z",
  "sent_at": "2026-09-30T17:56:09Z",
  "transcript": "Call Sarah about the CTO search...",
  "transcript_status": "complete",
  "transcription_mode": "onDevice",
  "audio_included": false,
  "app": {
    "platform": "ios",
    "version": "1.9",
    "build": "17"
  }
}
```

| Field | Meaning |
|---|---|
| `recording_id` | Stable for each recording. Save by it. Test events use `test-` plus a timestamp. |
| `transcript_status` | `complete` for a real transcript. `placeholder` while transcription is pending or failed. When the real transcript is ready, Pigeon sends again with the same `recording_id`. |
| `transcription_mode` | `onDevice`, `appleCloud`, or `openRouter`. Left out of test events. |
| `recorded_at`, `sent_at` | ISO 8601 in UTC. `sent_at` is set on each attempt. |
| `audio_included` | `true` when the `audio` part is attached. |

New fields can show up later. Ignore any you don't use.

## Responses and retries

| Your response | What Pigeon does |
|---|---|
| 2xx | Delivered. The body is ignored. |
| 408 429 5xx no answer | Retries for up to 3 days. |
| 413 | Sends that recording again without the audio. The app shows **audio too large, sent transcript only**. |
| any other 4xx | Holds the recording until you edit the webhook or a test succeeds. Then everything waiting goes out. |

Answer within 2 minutes, or Pigeon counts it as no answer. The app shows the status and the start of your error, like `HTTP 401: bad token`.

After 3 days Pigeon stops trying. The recording stays on the phone, and the app says how many it gave up on.

A memo recorded offline goes out once the phone is back online.

## Receiver rules

- Check the secret on every request. Return 401 if it's wrong.
- Return 200 before you do the slow work. If you answer too late, Pigeon retries and you get the same recording again.
- Save by `recording_id`, and let the newest `sent_at` win. The same recording can arrive twice: a retry, or a placeholder and then the real transcript.
- Run your agent once per `recording_id`, when `event` is `recording` and `transcript_status` is `complete`.
