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:writescope. - Discord channel incoming webhook url.
buninstalled (https://bun.sh). swap tonpm/pnpmby editingpackage.jsonscripts if needed.
setup#
all commands run from the repo root.
-
copy config template:
cp wrangler.toml.example wrangler.toml -
install deps:
bun install -
authenticate with Cloudflare (one-time):
bunx wrangler login -
(optional) pin deploys to a specific Cloudflare account:
export CLOUDFLARE_ACCOUNT_ID=<account-id> # from `bunx wrangler whoami` -
create KV namespace. paste the returned
idintowrangler.tomlunder[[kv_namespaces]]:bunx wrangler kv namespace create FIGMA_DISCORD_WEBHOOK -
map your Figma
file_keyto the Discord webhook url:bunx wrangler kv key put --binding=FIGMA_DISCORD_WEBHOOK --remote \ "<FIGMA_FILE_KEY>" "<DISCORD_WEBHOOK_URL>" -
(optional) bind a custom domain. uncomment and edit the
[[routes]]block inwrangler.toml:[[routes]] pattern = "figma.example.com" custom_domain = truethe parent zone must already exist on this Cloudflare account.
-
deploy:
bun run deploynote the deployed url (either
<name>.<subdomain>.workers.devor your custom domain). -
set the passcode secret (random string, used for auth between Figma and Worker):
openssl rand -hex 32 | bunx wrangler secret put FIGMA_PASSCODEsave the same value; step 10 uses it.
-
register the Figma webhook:
curl -X POST \ -H "X-FIGMA-TOKEN: <FIGMA_PAT>" \ -H "Content-Type: application/json" \ "https://api.figma.com/v2/webhooks" \ -d '{ "event_type": "LIBRARY_PUBLISH", "team_id": "<FIGMA_TEAM_ID>", "endpoint": "<WORKER_URL>/figma", "passcode": "<SAME_PASSCODE_AS_STEP_9>", "description": "Design system publish -> Discord" }'Figma sends
PINGimmediately. Worker returns 200; no Discord message is posted forPING. -
test: publish a library change in Figma. Discord message arrives ~60s later.
endpoints#
POST /figma— Figma webhook receiver. validatespasscode. acceptsPINGandLIBRARY_PUBLISH. other event types return 200 and are ignored.GET /health— returns{"status":"healthy","lastFigmaRequestAt":<iso>|null}as JSON with 200.lastFigmaRequestAtis 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 infigma.com/file/<KEY>/…orfigma.com/design/<KEY>/….FIGMA_TEAM_ID: path segment infigma.com/files/team/<ID>/….CLOUDFLARE_ACCOUNT_ID: frombunx 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 = 15FIELD_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: <PAT>" \
"https://api.figma.com/v2/webhooks/<WEBHOOK_ID>/requests"
list webhooks on a team:
curl -H "X-FIGMA-TOKEN: <PAT>" \
"https://api.figma.com/v2/teams/<TEAM_ID>/webhooks"
delete a webhook:
curl -X DELETE -H "X-FIGMA-TOKEN: <PAT>" \
"https://api.figma.com/v2/webhooks/<WEBHOOK_ID>"
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. the #exclude opt-out convention is borrowed from that project. no code was copied.
license#
MIT. see LICENSE.