pigeon

Webhooks

Pigeon can send each recording to a URL you control. iPhone only for now.

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.

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

Build a Pigeon webhook receiver on Cloudflare Workers. The contract is at https://pigeon.newyorkai.org/webhooks. 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.

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

Build a Pigeon webhook receiver as a Vercel function at app/api/pigeon/route.js. The contract is at https://pigeon.newyorkai.org/webhooks. 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.

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

HeaderValue
AuthorizationBearer <secret>. Left out when the secret is blank.
X-Pigeon-Eventrecording, or test for send test
X-Pigeon-Recording-IdSame as recording_id
X-Pigeon-Version1
PartContents
payloadJSON, always sent. It’s a text part, not a file.
audioThe .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:

{
  "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"
  }
}
FieldMeaning
recording_idStable for each recording. Save by it. Test events use test- plus a timestamp.
transcript_statuscomplete 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_modeonDevice, appleCloud, or openRouter. Left out of test events.
recorded_at, sent_atISO 8601 in UTC. sent_at is set on each attempt.
audio_includedtrue when the audio part is attached.

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

Responses and retries

Your responseWhat Pigeon does
2xxDelivered. The body is ignored.
408 429 5xx no answerRetries for up to 3 days.
413Sends that recording again without the audio. The app shows audio too large, sent transcript only.
any other 4xxHolds 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.