a little carrier pigeon that ferries figma events to discord
1# pigeon
2
3a 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.
4
5stack: Cloudflare Workers + Durable Objects (SQLite storage + alarms) + Workers KV. no external services, no runtime deps.
6
7## prerequisites
8
9- Cloudflare account. Workers Free is sufficient.
10- Figma team admin access + personal access token with `webhooks:write` scope.
11- Discord channel incoming webhook url.
12- `bun` installed (`https://bun.sh`). swap to `npm`/`pnpm` by editing `package.json` scripts if needed.
13
14## setup
15
16all commands run from the repo root.
17
181. copy config template:
19
20 ```
21 cp wrangler.toml.example wrangler.toml
22 ```
23
242. install deps:
25
26 ```
27 bun install
28 ```
29
303. authenticate with Cloudflare (one-time):
31
32 ```
33 bunx wrangler login
34 ```
35
364. (optional) pin deploys to a specific Cloudflare account:
37
38 ```
39 export CLOUDFLARE_ACCOUNT_ID=<account-id> # from `bunx wrangler whoami`
40 ```
41
425. create KV namespace. paste the returned `id` into `wrangler.toml` under `[[kv_namespaces]]`:
43
44 ```
45 bunx wrangler kv namespace create FIGMA_DISCORD_WEBHOOK
46 ```
47
486. map your Figma `file_key` to the Discord webhook url:
49
50 ```
51 bunx wrangler kv key put --binding=FIGMA_DISCORD_WEBHOOK --remote \
52 "<FIGMA_FILE_KEY>" "<DISCORD_WEBHOOK_URL>"
53 ```
54
557. (optional) bind a custom domain. uncomment and edit the `[[routes]]` block in `wrangler.toml`:
56
57 ```
58 [[routes]]
59 pattern = "figma.example.com"
60 custom_domain = true
61 ```
62
63 the parent zone must already exist on this Cloudflare account.
64
658. deploy:
66
67 ```
68 bun run deploy
69 ```
70
71 note the deployed url (either `<name>.<subdomain>.workers.dev` or your custom domain).
72
739. set the passcode secret (random string, used for auth between Figma and Worker):
74
75 ```
76 openssl rand -hex 32 | bunx wrangler secret put FIGMA_PASSCODE
77 ```
78
79 save the same value; step 10 uses it.
80
8110. register the Figma webhook:
82
83 ```
84 curl -X POST \
85 -H "X-FIGMA-TOKEN: <FIGMA_PAT>" \
86 -H "Content-Type: application/json" \
87 "https://api.figma.com/v2/webhooks" \
88 -d '{
89 "event_type": "LIBRARY_PUBLISH",
90 "team_id": "<FIGMA_TEAM_ID>",
91 "endpoint": "<WORKER_URL>/figma",
92 "passcode": "<SAME_PASSCODE_AS_STEP_9>",
93 "description": "Design system publish -> Discord"
94 }'
95 ```
96
97 Figma sends `PING` immediately. Worker returns 200; no Discord message is posted for `PING`.
98
9911. test: publish a library change in Figma. Discord message arrives ~60s later.
100
101## endpoints
102
103- `POST /figma` — Figma webhook receiver. validates `passcode`. accepts `PING` and `LIBRARY_PUBLISH`. other event types return 200 and are ignored.
104- `GET /health` — returns `{"status":"healthy","lastFigmaRequestAt":<iso>|null}` as JSON with 200. `lastFigmaRequestAt` is the ISO timestamp of the most recent authenticated Figma webhook delivery, useful for detecting upstream silence.
105
106all other routes return 404.
107
108## opt-out
109
110include `#exclude` (case-insensitive) anywhere in the Figma publish description. Worker drops the event.
111
112## key / value reference
113
114- `FIGMA_FILE_KEY`: path segment in `figma.com/file/<KEY>/…` or `figma.com/design/<KEY>/…`.
115- `FIGMA_TEAM_ID`: path segment in `figma.com/files/team/<ID>/…`.
116- `CLOUDFLARE_ACCOUNT_ID`: from `bunx wrangler whoami`.
117- `DISCORD_WEBHOOK_URL`: Discord channel → edit channel → integrations → webhooks → new webhook.
118- `FIGMA_PAT`: Figma → settings → security → personal access tokens. scope: `webhooks:write`.
119
120## files
121
122- `src/index.ts` — fetch handler, passcode check, route to Batcher DO.
123- `src/batcher.ts` — Durable Object. merges items by key, schedules 60s alarm, flushes to Discord, one retry on failure.
124- `src/discord.ts` — embed builder + POST. respects Discord field limits (1024 char value, 15 items per field).
125- `src/figma.ts` — payload types, `isExcluded()`, `figmaFileUrl()`, `collectChanged()`.
126- `src/types.ts` — `Env`, `BatchState`, `BatchItems`, `LibraryItem`.
127
128## config knobs
129
130in `src/batcher.ts`:
131
132- `BATCH_WINDOW_MS = 60_000` — coalescing window from first event.
133- `RETRY_DELAY_MS = 300_000` — delay before single retry on Discord failure.
134- `MAX_FLUSH_ATTEMPTS = 2` — total attempts (initial + retry). higher values risk loops.
135
136in `src/discord.ts`:
137
138- `MAX_ITEMS_PER_FIELD = 15`
139- `FIELD_VALUE_MAX = 1024`
140
141## Figma event handling
142
143input: 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)`.
144
145output: 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.
146
147## limits
148
149- Figma: 20 webhooks per team. team-context webhooks do not fire for invite-only project files.
150- Discord: 1024 chars per field value, 6000 chars per embed total. enforced in `src/discord.ts`.
151- Workers Free: ample for typical design system publish frequency.
152
153## debugging
154
155live logs:
156
157```
158bun run tail
159```
160
161Figma webhook delivery history (last 7 days):
162
163```
164curl -H "X-FIGMA-TOKEN: <PAT>" \
165 "https://api.figma.com/v2/webhooks/<WEBHOOK_ID>/requests"
166```
167
168list webhooks on a team:
169
170```
171curl -H "X-FIGMA-TOKEN: <PAT>" \
172 "https://api.figma.com/v2/teams/<TEAM_ID>/webhooks"
173```
174
175delete a webhook:
176
177```
178curl -X DELETE -H "X-FIGMA-TOKEN: <PAT>" \
179 "https://api.figma.com/v2/webhooks/<WEBHOOK_ID>"
180```
181
182typecheck source:
183
184```
185bun run typecheck
186```
187
188local dev (needs a tunnel to receive Figma webhooks):
189
190```
191cp .dev.vars.example .dev.vars # fill in FIGMA_PASSCODE
192bun run dev
193```
194
195## acknowledgements
196
197inspired 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.
198
199## license
200
201MIT. see `LICENSE`.