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.

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

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.
- Send a signed fixture with the wrong
appId. The relay should return204and create no Ping. - Send the selected app with
status: "errored"or"canceled". It should also return204. - Finish one build for the selected app, profile, and platform. One EAS build finished Ping should appear.
- Replay the exact signed body. It should not create a second Ping.
- Change one byte without recomputing the signature. The relay should return
401. - 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.


