# pigeon a little carrier pigeon that ferries Figma `LIBRARY_PUBLISH` events to a Discord channel webhook. Cloudflare Worker. coalesces per-asset-type events into a single Discord embed inside a 60s window via a Durable Object (one DO instance per `file_key`). one retry at +5 min on Discord failure, then drop. stack: Cloudflare Workers + Durable Objects (SQLite storage + alarms) + Workers KV. no external services, no runtime deps. ## prerequisites - Cloudflare account. Workers Free is sufficient. - Figma team admin access + personal access token with `webhooks:write` scope. - Discord channel incoming webhook url. - `bun` installed (`https://bun.sh`). swap to `npm`/`pnpm` by editing `package.json` scripts if needed. ## setup all commands run from the repo root. 1. copy config template: ``` cp wrangler.toml.example wrangler.toml ``` 2. install deps: ``` bun install ``` 3. authenticate with Cloudflare (one-time): ``` bunx wrangler login ``` 4. (optional) pin deploys to a specific Cloudflare account: ``` export CLOUDFLARE_ACCOUNT_ID= # from `bunx wrangler whoami` ``` 5. create KV namespace. paste the returned `id` into `wrangler.toml` under `[[kv_namespaces]]`: ``` bunx wrangler kv namespace create FIGMA_DISCORD_WEBHOOK ``` 6. map your Figma `file_key` to the Discord webhook url: ``` bunx wrangler kv key put --binding=FIGMA_DISCORD_WEBHOOK --remote \ "" "" ``` 7. (optional) bind a custom domain. uncomment and edit the `[[routes]]` block in `wrangler.toml`: ``` [[routes]] pattern = "figma.example.com" custom_domain = true ``` the parent zone must already exist on this Cloudflare account. 8. deploy: ``` bun run deploy ``` note the deployed url (either `..workers.dev` or your custom domain). 9. set the passcode secret (random string, used for auth between Figma and Worker): ``` openssl rand -hex 32 | bunx wrangler secret put FIGMA_PASSCODE ``` save the same value; step 10 uses it. 10. register the Figma webhook: ``` curl -X POST \ -H "X-FIGMA-TOKEN: " \ -H "Content-Type: application/json" \ "https://api.figma.com/v2/webhooks" \ -d '{ "event_type": "LIBRARY_PUBLISH", "team_id": "", "endpoint": "/figma", "passcode": "", "description": "Design system publish -> Discord" }' ``` Figma sends `PING` immediately. Worker returns 200; no Discord message is posted for `PING`. 11. test: publish a library change in Figma. Discord message arrives ~60s later. ## endpoints - `POST /figma` — Figma webhook receiver. validates `passcode`. accepts `PING` and `LIBRARY_PUBLISH`. other event types return 200 and are ignored. - `GET /health` — returns `{"status":"healthy","lastFigmaRequestAt":|null}` as JSON with 200. `lastFigmaRequestAt` is the ISO timestamp of the most recent authenticated Figma webhook delivery, useful for detecting upstream silence. all other routes return 404. ## opt-out include `#exclude` (case-insensitive) anywhere in the Figma publish description. Worker drops the event. ## key / value reference - `FIGMA_FILE_KEY`: path segment in `figma.com/file//…` or `figma.com/design//…`. - `FIGMA_TEAM_ID`: path segment in `figma.com/files/team//…`. - `CLOUDFLARE_ACCOUNT_ID`: from `bunx wrangler whoami`. - `DISCORD_WEBHOOK_URL`: Discord channel → edit channel → integrations → webhooks → new webhook. - `FIGMA_PAT`: Figma → settings → security → personal access tokens. scope: `webhooks:write`. ## files - `src/index.ts` — fetch handler, passcode check, route to Batcher DO. - `src/batcher.ts` — Durable Object. merges items by key, schedules 60s alarm, flushes to Discord, one retry on failure. - `src/discord.ts` — embed builder + POST. respects Discord field limits (1024 char value, 15 items per field). - `src/figma.ts` — payload types, `isExcluded()`, `figmaFileUrl()`, `collectChanged()`. - `src/types.ts` — `Env`, `BatchState`, `BatchItems`, `LibraryItem`. ## config knobs in `src/batcher.ts`: - `BATCH_WINDOW_MS = 60_000` — coalescing window from first event. - `RETRY_DELAY_MS = 300_000` — delay before single retry on Discord failure. - `MAX_FLUSH_ATTEMPTS = 2` — total attempts (initial + retry). higher values risk loops. in `src/discord.ts`: - `MAX_ITEMS_PER_FIELD = 15` - `FIELD_VALUE_MAX = 1024` ## Figma event handling input: Figma `LIBRARY_PUBLISH` payload. separate events fire per asset type (components, styles, variables) within milliseconds of each other. all three categories are merged into one batch per `file_key` via `env.BATCHER.idFromName(file_key)`. output: one Discord embed per batch window. fields: components (N), styles (N), variables (N). each field lists up to 15 item names; overflow renders as "+ X more". deleted items are not shown. ## limits - Figma: 20 webhooks per team. team-context webhooks do not fire for invite-only project files. - Discord: 1024 chars per field value, 6000 chars per embed total. enforced in `src/discord.ts`. - Workers Free: ample for typical design system publish frequency. ## debugging live logs: ``` bun run tail ``` Figma webhook delivery history (last 7 days): ``` curl -H "X-FIGMA-TOKEN: " \ "https://api.figma.com/v2/webhooks//requests" ``` list webhooks on a team: ``` curl -H "X-FIGMA-TOKEN: " \ "https://api.figma.com/v2/teams//webhooks" ``` delete a webhook: ``` curl -X DELETE -H "X-FIGMA-TOKEN: " \ "https://api.figma.com/v2/webhooks/" ``` typecheck source: ``` bun run typecheck ``` local dev (needs a tunnel to receive Figma webhooks): ``` cp .dev.vars.example .dev.vars # fill in FIGMA_PASSCODE bun run dev ``` ## acknowledgements inspired by [`bryanberger/figma-discord-webhook`](https://github.com/bryanberger/figma-discord-webhook). the `#exclude` opt-out convention is borrowed from that project. no code was copied. ## license MIT. see `LICENSE`.