Skip to main content
All posts
Automation8 min read

How to Send a Figma Named-Version Alert to Your Team's Phones

Verify Figma's FILE_VERSION_UPDATE webhook, allow one file, and send fixed privacy-minimal copy to a shared PingRoom Room.

Two PingRoom avatars guide one sealed clay version token through a small verification gate while Pingo watches.

To alert a small team when someone creates a named version in one Figma file, create a file-context FILE_VERSION_UPDATE webhook, verify Figma's passcode in a relay, allow only that file key, and forward fixed copy to a PingRoom incoming webhook. Use the Figma webhook ID and version ID for retry identity. Keep the file name, version label, description, and user identity in Figma.

This is a webhook recipe, not a native Figma integration. The destination Room owner needs PingRoom Pro.

Use a named version as the deliberate signal

Figma's FILE_VERSION_UPDATE event fires when a user creates a named version in a file's version history. That makes it more deliberate than a broad “file changed” alert. A team can agree that a named version means “there is a stable checkpoint worth reviewing.”

It does not prove that a design is approved, ready for development, or safe to publish. If one client must make a recorded choice, use a versioned design-approval Question instead. Keep annotations, visual comparison, and detailed feedback in Figma or a dedicated proofing tool.

NeedBetter starting point
A collaborator needs comments, pixels, or version historyFigma
A client must choose approve or reviseVersioned review link + explicit Question
A named checkpoint should reach one small group away from FigmaFigma webhook → relay → PingRoom Room
A status must be derived from file contents automaticallyA reviewed workflow with its own validation

Create a bounded destination Room

Create or choose a private PingRoom Room for the stable group that needs this signal. Open Settings → Connections → From another app, add a webhook named Figma version, choose Custom message, and copy the full URL.

The incoming-webhook setup guide covers the Room-side steps. The webhook reference documents current fields, limits, idempotency behavior, and errors.

Store these four values in protected server configuration:

  • FIGMA_FILE_KEY: the exact file allowed to send;
  • FIGMA_PASSCODE: a new random value, no more than 100 characters, used only for this Figma webhook;
  • PINGROOM_WEBHOOK_URL: the complete secret PingRoom webhook URL;
  • PINGROOM_FILE_LABEL: short reviewed copy such as Design system.

Do not place either secret in browser code, source control, screenshots, request-body logs, or the Ping itself.

Verify the passcode and discard the rich payload

Figma requires a passcode when a webhook is created and returns it in each event. Figma's security guidance says to compare the returned passcode with the original and answer with HTTP 400 when it does not match.

The named-version payload also contains the file name, version label, description, and triggering user. The team may need those details inside Figma, but a lock-screen alert does not. This dependency-free Web Crypto handler uses only the event type, file key, webhook ID, and version ID.

const MAX_BODY_BYTES = 128_000;

function reply(status, body) {
  return Response.json(body, { status });
}

async function sha256(value) {
  const bytes = await crypto.subtle.digest(
    "SHA-256",
    new TextEncoder().encode(value),
  );
  return [...new Uint8Array(bytes)]
    .map((byte) => byte.toString(16).padStart(2, "0"))
    .join("");
}

async function equalSecrets(received, expected) {
  if (typeof received !== "string" || typeof expected !== "string") {
    return false;
  }

  const [receivedHash, expectedHash] = await Promise.all([
    sha256(received),
    sha256(expected),
  ]);
  let difference = 0;
  for (let index = 0; index < receivedHash.length; index += 1) {
    difference |=
      receivedHash.charCodeAt(index) ^ expectedHash.charCodeAt(index);
  }
  return difference === 0;
}

export async function handleFigmaWebhook(request, env, fetchImpl = fetch) {
  if (request.method !== "POST") {
    return reply(405, { error: "method_not_allowed" });
  }

  const {
    FIGMA_FILE_KEY,
    FIGMA_PASSCODE,
    PINGROOM_FILE_LABEL,
    PINGROOM_WEBHOOK_URL,
  } = env;

  if (
    !FIGMA_FILE_KEY ||
    !FIGMA_PASSCODE ||
    !PINGROOM_FILE_LABEL ||
    PINGROOM_FILE_LABEL.length > 48 ||
    !PINGROOM_WEBHOOK_URL?.startsWith("https://")
  ) {
    return reply(500, { error: "server_configuration_error" });
  }

  const declaredLength = Number(request.headers.get("content-length") ?? 0);
  if (declaredLength > MAX_BODY_BYTES) {
    return reply(413, { error: "body_too_large" });
  }

  const rawBody = await request.text();
  if (new TextEncoder().encode(rawBody).byteLength > MAX_BODY_BYTES) {
    return reply(413, { error: "body_too_large" });
  }

  let event;
  try {
    event = JSON.parse(rawBody);
  } catch {
    return reply(400, { error: "invalid_json" });
  }
  if (!event || typeof event !== "object" || Array.isArray(event)) {
    return reply(400, { error: "invalid_json" });
  }

  if (!(await equalSecrets(event.passcode, FIGMA_PASSCODE))) {
    return reply(400, { error: "invalid_passcode" });
  }

  if (event.event_type === "PING") {
    return reply(200, { ok: true, ping: true });
  }

  if (event.event_type !== "FILE_VERSION_UPDATE") {
    return reply(200, { ok: true, ignored: true });
  }

  if (event.file_key !== FIGMA_FILE_KEY) {
    return reply(200, { ok: true, ignored: true });
  }

  const webhookId = String(event.webhook_id ?? "");
  const versionId = String(event.version_id ?? "");
  if (
    !webhookId ||
    webhookId.length > 128 ||
    !versionId ||
    versionId.length > 255
  ) {
    return reply(400, { error: "invalid_event_identity" });
  }

  const digest = await sha256(`${webhookId}:${versionId}`);
  const eventKey = `figma-version-${digest.slice(0, 32)}`;
  const payload = {
    title: "Figma version saved",
    message: `${PINGROOM_FILE_LABEL} has a named version. Open Figma to review.`,
    correlation_id: eventKey,
  };

  try {
    const response = await fetchImpl(PINGROOM_WEBHOOK_URL, {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        "Idempotency-Key": eventKey,
      },
      body: JSON.stringify(payload),
      signal: AbortSignal.timeout(8_000),
    });

    if (!response.ok) {
      return reply(503, { error: "pingroom_rejected" });
    }

    const accepted = await response.json().catch(() => null);
    if (accepted?.success !== true) {
      return reply(503, { error: "pingroom_invalid_response" });
    }

    return reply(200, { ok: true });
  } catch {
    return reply(503, { error: "pingroom_unavailable" });
  }
}

Hashing both passcodes to equal-length values lets the comparison inspect every digest character without an early byte mismatch. It is a practical JavaScript comparison, not a guarantee about runtime timing behavior. Keep the public relay behind normal platform request limits as well.

The handler never puts the Figma file name, version label, description, user handle, or file URL into the downstream body. PINGROOM_FILE_LABEL is reviewed server configuration. Use neutral copy such as Selected design file if the file's purpose is sensitive.

Create the file-context Figma webhook

Figma's webhook endpoint uses POST /v2/webhooks and requires the webhooks:write scope. A file-context webhook can be created by someone with edit permission on that file. Figma currently allows three webhooks per file.

For individual tooling, create a personal access token with only the needed scope. Figma documents the X-Figma-Token header. For organization-owned automation, review Figma's current OAuth and plan-token options instead of tying the connection to a person indefinitely.

Replace the placeholders, then create the webhook once:

curl --fail-with-body --silent --show-error \
  -X POST "https://api.figma.com/v2/webhooks" \
  -H "X-Figma-Token: $FIGMA_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{
    "event_type": "FILE_VERSION_UPDATE",
    "context": "file",
    "context_id": "YOUR_FIGMA_FILE_KEY",
    "endpoint": "https://YOUR_RELAY.example/figma",
    "passcode": "YOUR_RANDOM_FIGMA_PASSCODE",
    "description": "Named version alert"
  }'

Use the same file key and passcode in the relay. Keep the Figma token out of the relay after creation unless the relay also manages webhooks. Figma sends an initial PING; a valid passcode should receive HTTP 200 without creating a PingRoom alert.

Make provider retries boring

Figma expects HTTP 200 from a successful endpoint. Its Webhooks V2 guide documents three retries for failed requests: after five minutes, 30 minutes, and three hours. The relay gives the same Figma webhook/version pair the same PingRoom body and Idempotency-Key.

Relay outcomeHTTP resultMeaning
Valid selected named version accepted200PingRoom accepted the fixed event
Valid PING, another event, or another file200Figma is acknowledged; no Room Ping is needed
Wrong passcode or malformed event identity400The event is not trusted or cannot be mapped
Body exceeds the relay's limit413The request is rejected before forwarding
PingRoom rejects, times out, or returns bad JSON503Figma can retry the same stable request

Idempotency makes repeated delivery of the same version safe at PingRoom. It is not a durable queue. If this alert must survive platform failure, put a queue between verification and delivery.

Two PingRoom avatars place one plain clay version token into a shared bowl while detailed design fragments stay behind a divider and Pingo watches the duplicate tray.

These AI-generated editorial illustrations feature approved PingRoom avatars and Pingo the Pigeon, PingRoom's fictional Chief Marketing Officer. They are not Figma or PingRoom screenshots and do not depict customers.

Pingo the Pigeon is watching the plain token cross the divider in this example. He has no Figma account, private-Room access, passcode, or authority to send Pings. People choose the file, Room, copy, and meaning of a named version.

Test the boundary before relying on it

Use mocked requests first, then one harmless named version:

  1. Send a valid PING and confirm HTTP 200 with no PingRoom request.
  2. Send one valid named-version event for the selected file and confirm fixed copy is accepted.
  3. Replay the same webhook/version pair and confirm the key and body are identical.
  4. Change the file key and confirm no PingRoom request is made.
  5. Change the event type to FILE_UPDATE and confirm it is ignored.
  6. Change the passcode and confirm HTTP 400.
  7. Remove the version ID and confirm HTTP 400.
  8. Exceed the body limit and confirm HTTP 413.
  9. Make PingRoom return 429 and confirm the relay returns 503 so Figma can retry.
  10. Confirm the downstream body contains no file name, version label, description, user identity, or Figma URL.

For this article, we imported the exact handler above and ran eleven cases with a stateful PingRoom mock. They covered acceptance, exact replay, provider PING, event and file filtering, passcode rejection, malformed JSON, non-object JSON, oversized input, missing identity, downstream rejection, and sensitive-field exclusion. No live Figma file, PingRoom Room, phone, or network request was used.

If a deliberate Figma checkpoint should reach a separate group, create the destination Room and webhook, deploy the relay, and create one harmless named version. If it does not arrive, inspect Figma's webhook request log before changing the workflow.

Figma is a trademark of Figma, Inc. PingRoom is not affiliated with or endorsed by Figma.

PingRoom

Short-form communication.

View all posts