Skip to main content
All posts
Developers7 min read

How to Send an Expo EAS Build-Finished Alert to Your Team's Phones

Verify Expo's signed build webhook, filter one app and build profile, and send one privacy-minimal finished-build alert to a shared PingRoom Room.

Two PingRoom avatars move a pale-green build token from a wooden press into a review tray while Pingo inspects it.

To send an Expo EAS Build completion to a small team's phones, create a project-level BUILD webhook, verify Expo's signature in a relay, filter one app and build profile, then send fixed copy to a PingRoom Room. Use the EAS build ID as the retry identity, and leave branch, commit, logs, artifact, account, and user details in Expo.

This is a webhook recipe, not a native Expo integration. A finished build means EAS reported that build as finished. It does not prove the app works, passed review, reached a store, or was deployed to users.

Check the Expo build record first

Expo's build workflow keeps its dashboard as the place to inspect status, logs, artifacts, profile, platform, and the person who started a build. If every recipient already watches that surface, keep the workflow there.

Add a shared Room only when one selected completion has a separate attention job.

NeedBetter starting point
A developer needs logs, artifacts, or failure detailsExpo's build dashboard
One selected finished build should reach a small shared groupExpo BUILD webhook → signed relay → PingRoom Room
A failed or canceled build needs engineering triageThe team's existing build and incident path
A finished build must be submitted or releasedKeep EAS Submit and the store workflow authoritative

This guide forwards only status: "finished". It quietly ignores errored and canceled payloads so they cannot be mistaken for a successful handoff.

Create the Room and keep both secrets server-side

Create or choose a private Room for the people who need the completion signal. The Room owner needs PingRoom Pro for incoming webhooks.

Open Settings → Connections → From another app, add a webhook named Expo build, leave Quick Ping on Custom message, and copy the full URL. The incoming-webhook walkthrough covers setup, while the webhook reference carries the current request limits and replay contract.

Store these values in the server-side environment of the relay you control:

EXPO_WEBHOOK_SECRET=<at least 16 random characters>
EXPO_APP_ID=<the appId in Expo's BUILD payload>
EXPO_BUILD_PROFILE=production
EXPO_PLATFORM=ios
EXPO_PROJECT_LABEL=Selected mobile app
PINGROOM_WEBHOOK_URL=<the complete PingRoom incoming-webhook URL>

Do not expose either secret in app code, a public environment variable, a build log, a screenshot, or source control. EXPO_PROJECT_LABEL is the only project wording sent to the phone. Keep it short and safe for a lock-screen preview.

Deploy a narrow signed receiver

The following dependency-free handler uses standard Request, Response, Web Crypto, and fetch APIs. It fits a modern Worker-style runtime or any server that can adapt an incoming HTTP request to this interface.

const MAX_BODY_BYTES = 64_000;

function constantTimeEqual(left, right) {
  if (left.length !== right.length) return false;
  let difference = 0;
  for (let index = 0; index < left.length; index += 1) {
    difference |= left.charCodeAt(index) ^ right.charCodeAt(index);
  }
  return difference === 0;
}

async function hmacSha1Hex(secret, rawBody) {
  const encoder = new TextEncoder();
  const key = await crypto.subtle.importKey(
    "raw",
    encoder.encode(secret),
    { name: "HMAC", hash: "SHA-1" },
    false,
    ["sign"],
  );
  const signature = await crypto.subtle.sign(
    "HMAC",
    key,
    encoder.encode(rawBody),
  );
  return [...new Uint8Array(signature)]
    .map((byte) => byte.toString(16).padStart(2, "0"))
    .join("");
}

export async function handleExpoBuildWebhook(request, env, fetchImpl = fetch) {
  const {
    EXPO_WEBHOOK_SECRET,
    EXPO_APP_ID,
    EXPO_BUILD_PROFILE,
    EXPO_PLATFORM,
    EXPO_PROJECT_LABEL,
    PINGROOM_WEBHOOK_URL,
  } = env;

  if (
    !EXPO_WEBHOOK_SECRET ||
    !EXPO_APP_ID ||
    !EXPO_BUILD_PROFILE ||
    !EXPO_PLATFORM ||
    !EXPO_PROJECT_LABEL ||
    !PINGROOM_WEBHOOK_URL
  ) {
    return new Response("Missing server configuration", { status: 500 });
  }

  if (
    EXPO_PROJECT_LABEL.length > 60 ||
    /[\r\n]/.test(EXPO_PROJECT_LABEL) ||
    !["ios", "android"].includes(EXPO_PLATFORM)
  ) {
    return new Response("Invalid server configuration", { status: 500 });
  }

  let pingroomUrl;
  try {
    pingroomUrl = new URL(PINGROOM_WEBHOOK_URL);
  } catch {
    return new Response("Invalid PINGROOM_WEBHOOK_URL", { status: 500 });
  }
  if (pingroomUrl.protocol !== "https:") {
    return new Response("PINGROOM_WEBHOOK_URL must use HTTPS", { status: 500 });
  }

  const rawBody = await request.text();
  if (new TextEncoder().encode(rawBody).byteLength > MAX_BODY_BYTES) {
    return new Response("Payload too large", { status: 413 });
  }

  const suppliedSignature = (request.headers.get("expo-signature") ?? "")
    .trim()
    .toLowerCase();
  const expectedSignature = await hmacSha1Hex(EXPO_WEBHOOK_SECRET, rawBody);
  if (
    !/^[a-f0-9]{40}$/.test(suppliedSignature) ||
    !constantTimeEqual(suppliedSignature, expectedSignature)
  ) {
    return new Response("Invalid signature", { status: 401 });
  }

  let build;
  try {
    build = JSON.parse(rawBody);
  } catch {
    return new Response("Invalid JSON", { status: 400 });
  }

  if (
    typeof build?.id !== "string" ||
    !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
      build.id,
    ) ||
    typeof build?.metadata !== "object" ||
    build.metadata === null
  ) {
    return new Response("Invalid BUILD payload", { status: 400 });
  }

  if (
    build.status !== "finished" ||
    build.appId !== EXPO_APP_ID ||
    build.metadata.buildProfile !== EXPO_BUILD_PROFILE ||
    build.platform !== EXPO_PLATFORM
  ) {
    return new Response(null, { status: 204 });
  }

  const eventId = `expo-eas-build:${build.id}`;
  const body = {
    action: null,
    title: "EAS build finished",
    message: `${EXPO_PROJECT_LABEL} · ${EXPO_PLATFORM} ${EXPO_BUILD_PROFILE} build is ready for review`,
    correlation_id: eventId,
  };

  let response;
  try {
    response = await fetchImpl(PINGROOM_WEBHOOK_URL, {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        "Idempotency-Key": eventId,
      },
      body: JSON.stringify(body),
      redirect: "error",
      signal: AbortSignal.timeout(10_000),
    });
  } catch {
    return new Response("Unable to reach PingRoom", { status: 502 });
  }

  if (!response.ok) {
    return new Response(`PingRoom returned HTTP ${response.status}`, {
      status: 502,
    });
  }

  let accepted;
  try {
    accepted = await response.json();
  } catch {
    return new Response("PingRoom returned invalid JSON", { status: 502 });
  }

  if (accepted.success !== true) {
    return new Response("PingRoom did not accept the build alert", {
      status: 502,
    });
  }

  return Response.json({
    accepted: true,
    notification_id: accepted.notification_id,
  });
}

Expo's webhook reference documents expo-signature as a hex HMAC-SHA1 digest of the exact request body. The receiver verifies those raw bytes before parsing JSON. It then checks the stable app ID, build profile, platform, and terminal status. A signed event for another app is still not this relay's event.

The 64 KB cap keeps an unexpected body from consuming unbounded memory. The phone copy is built from server configuration, not from commit messages, account names, usernames, errors, artifact URLs, or other payload fields.

Create the Expo BUILD webhook

The current EAS CLI creates webhooks per project. From the intended project directory, run:

eas webhook:create

Choose BUILD, enter the public HTTPS URL for your relay, and enter the same signing secret stored as EXPO_WEBHOOK_SECRET. Expo requires a secret of at least 16 characters. Use a cryptographically random value rather than a memorable phrase.

Run eas webhook:list to confirm the event type and destination. Use eas webhook:update --id WEBHOOK_ID to rotate the URL or secret, and eas webhook:delete when the route is no longer needed. Repeat the setup for another Expo project only if it has its own deliberate destination and filter.

Two PingRoom avatars pass one sealed parcel through a brass verification arch while Pingo watches an identical replay remain outside the gate.

Pingo the Pigeon, PingRoom's fictional Chief Marketing Officer, is watching the second parcel remain outside the gate. He cannot read Expo payloads, verify signatures, enter a private Room, or send the Ping. The human team chooses the project, filter, recipients, copy, secrets, and failure policy.

These AI-generated editorial illustrations feature approved PingRoom avatars and Pingo. They are not Expo or PingRoom screenshots and do not depict customers.

Give one build one replay identity

Expo's build id becomes both the PingRoom Idempotency-Key and correlation_id. If Expo retries the same signed request, the relay sends the same body under the same key, so PingRoom can return the original accepted result instead of creating another Ping.

Do not append a timestamp or retry count to that key. Those values would turn one source event into several apparent events. Do not reuse the build ID with changed copy either; PingRoom rejects a changed body under an existing idempotency key.

Expo retries a webhook when the endpoint returns a status outside the 200–399 range. The receiver returns 204 for valid but irrelevant payloads and 502 when PingRoom cannot accept the selected event, allowing the source's normal retry behavior to remain visible.

Test the filter before production traffic

Use a harmless development project and a private test Room.

  1. Send a signed fixture with the wrong appId. The relay should return 204 and create no Ping.
  2. Send the selected app with status: "errored" or "canceled". It should also return 204.
  3. Finish one build for the selected app, profile, and platform. One EAS build finished Ping should appear.
  4. Replay the exact signed body. It should not create a second Ping.
  5. Change one byte without recomputing the signature. The relay should return 401.
  6. Open Expo's build dashboard and verify the actual build before installing, submitting, or releasing anything.

For this article, we ran the exact embedded handler against signed fixtures and a stateful local mock of PingRoom's documented contract. The checks cover signature failure, body limits, invalid JSON, every filter, first acceptance, exact replay, changed-body conflict, redirects, HTTP rejection, malformed downstream JSON, fixed-copy privacy, and required headers. They do not use a live Expo project, PingRoom Room, phone, or notification receipt.

An HTTP success from the relay means PingRoom accepted and queued the selected Ping. Connectivity, notification permissions, Focus modes, Room mutes, and operating-system policy still decide what appears on each phone. Expo remains the build record.

If that narrow completion deserves a shared signal, create the destination Room and webhook, deploy the receiver, and test one non-sensitive build. For broader event design, use the CI/CD notification guide. For a process running on your own machine, use the separate long-running command completion guide.

Expo and EAS are trademarks of 650 Industries, Inc. PingRoom is not affiliated with or endorsed by Expo.

PingRoom

Short-form communication.

View all posts