a little carrier pigeon that ferries figma events to discord
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

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=<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 \
      "<FIGMA_FILE_KEY>" "<DISCORD_WEBHOOK_URL>"
    
  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 <name>.<subdomain>.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: <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 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":<iso>|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/<KEY>/… or figma.com/design/<KEY>/….
  • FIGMA_TEAM_ID: path segment in figma.com/files/team/<ID>/….
  • 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.tsEnv, 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: <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.