# 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.

- Published: 2026-10-28
- Author: PingRoom
- Category: Developers
- Topics: Expo EAS build notification, EAS build webhook, Expo build finished push notification
- Canonical: https://pingroom.io/blog/expo-eas-build-finished-push-notifications
- Cover image: https://pingroom.io/blog/expo-eas-build-finished-push-notifications-cover.webp
- Cover description: 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](https://docs.expo.dev/build/setup/) 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.

| Need                                                          | Better starting point                                |
| ------------------------------------------------------------- | ---------------------------------------------------- |
| A developer needs logs, artifacts, or failure details         | Expo's build dashboard                               |
| One selected finished build should reach a small shared group | Expo BUILD webhook → signed relay → PingRoom Room    |
| A failed or canceled build needs engineering triage           | The team's existing build and incident path          |
| A finished build must be submitted or released                | Keep 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](/blog/webhook-to-push-notification) covers setup, while the [webhook reference](/documents/webhooks) carries the current request limits and replay contract.

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

```text
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.

```js
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](https://docs.expo.dev/eas/webhooks/) 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](https://docs.expo.dev/eas/cli/) creates webhooks per project. From the intended project directory, run:

```bash
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.](/blog/expo-eas-build-finished-push-notifications-1.webp)

[Pingo the Pigeon](/Pingo), 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](/app/rooms), deploy the receiver, and test one non-sensitive build. For broader event design, use the [CI/CD notification guide](/blog/ci-cd-deployment-notifications). For a process running on your own machine, use the separate [long-running command completion guide](/blog/notify-when-long-running-command-finishes).

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