Guide to Expo Webhooks: Features and Best Practices
Expo webhooks tell you when your app's cloud builds and store submissions finish. If you build with Expo Application Services (EAS), a webhook can post to Slack when a build is ready, start a QA run with the new binary, or record when a submission to the App Store or Google Play fails, without polling EAS for job status.
This guide covers how EAS webhooks are created and signed, what the build and submit payloads contain, the gaps in what Expo documents, and the practices that keep an Expo receiver reliable.
What are Expo webhooks?
An EAS webhook belongs to one project and one event type. The two event types are BUILD, sent when an EAS Build completes, and SUBMIT, sent when an EAS Submit job completes. "Completes" covers all three outcomes: the status field is finished, errored or canceled.
You create webhooks with the EAS CLI, and you choose the signing secret yourself. EAS sends each notification as a POST with a JSON object in the body and an expo-signature header containing sha1= followed by a hex HMAC-SHA1 of the raw body.
The event type is not in the payload. Expo sets it when you create the webhook and never sends it, so a receiver that handles both types has to recognise them by their fields, or receive them on different URLs.
Expo webhook features
| Feature | Details |
|---|---|
| Configuration | EAS CLI: eas webhook:create, webhook:update, webhook:list, webhook:delete |
| Scope | Per project and per event type (BUILD or SUBMIT) |
| Triggers | A build or submission finishing, erroring or being canceled |
| Signature header | expo-signature: sha1=<hex> |
| Scheme | HMAC-SHA1, hex |
| Signed content | The raw request body only |
| Secret | Chosen by you, at least 16 characters |
| Event type in payload | None. Tell build and submit payloads apart by their fields |
| Success | Any HTTP status from 200 to 399 |
| Retries | "A few more times with exponential back-off". No count or schedule is published |
| Replay protection | None. No timestamp or event id is sent |
Expo publishes no source IP list, no response timeout and no test event. To test, run a real build or submission.
Common events
There are two webhook types and three statuses.
| Webhook | Identifying fields | status values |
|---|---|---|
BUILD | buildDetailsPageUrl, artifacts, metadata, metrics | finished, errored, canceled |
SUBMIT | submissionDetailsPageUrl, submissionInfo, archiveUrl, turtleBuildId | finished, errored, canceled |
Both carry id, accountName, projectName, appId, platform (ios or android), status and ISO 8601 timestamps such as createdAt, updatedAt and completedAt. Here is the start of Expo's build example, trimmed:
{
"id": "147a3212-49fd-446f-b4e3-a6519acf264a",
"accountName": "dsokal",
"projectName": "example",
"buildDetailsPageUrl": "https://expo.dev/accounts/dsokal/projects/example/builds/147a3212-49fd-446f-b4e3-a6519acf264a",
"appId": "bc0a82de-65a5-4497-ad86-54ff1f53edf7",
"platform": "android",
"status": "errored",
"artifacts": {
"logsS3KeyPrefix": "production/f9609423-5072-4ea2-a0a5-c345eedf2c2a"
},
"metadata": {
"appVersion": "1.0.2",
"buildProfile": "production",
"distribution": "store",
"gitCommitHash": "564b61ebdd403d28b5dc616a12ce160b91585b5b"
},
"error": {
"message": "Unknown error. Please see logs.",
"errorCode": "UNKNOWN_ERROR"
},
"createdAt": "2021-11-24T09:53:01.155Z",
"completedAt": "2021-11-24T09:57:42.715Z"
}
Some fields appear only in some states. artifacts.buildUrl is present for successful builds, error for failed builds, cancelingUserId for canceled jobs, and parentBuildId or parentSubmissionId for retries. On submissions, a failure puts error inside submissionInfo, next to a logsUrl.
Setting up Expo webhooks
- Generate a secret of at least 16 characters, for example with
openssl rand -hex 32, and store it asEXPO_WEBHOOK_SECRET. - From the project directory, create a webhook for each event type you need:
eas webhook:create --event BUILD --url https://example.com/webhooks/expo --secret "$EXPO_WEBHOOK_SECRET"
eas webhook:create --event SUBMIT --url https://example.com/webhooks/expo --secret "$EXPO_WEBHOOK_SECRET"
- Repeat in every project you want notifications for. Webhooks are per project, so
@johndoe/awesomeAppand@johndoe/coolAppeach need their own.
Without the flags, the CLI prompts for the event type, URL and secret. To change a webhook's URL or secret later, find its ID with eas webhook:list and run eas webhook:update --id WEBHOOK_. eas webhook:delete stops deliveries.
Securing Expo webhooks
Compute an HMAC-SHA1 of the raw request body with your secret, hex-encode it, prefix it with sha1=, and compare it with the expo-signature header in constant time.
const crypto = require('crypto');
const express = require('express');
function verifyExpoSignature(rawBody, signatureHeader, secret) {
if (!signatureHeader || !secret) return false;
const expected = Buffer.from(
'sha1=' + crypto.createHmac('sha1', secret).update(rawBody).digest('hex'),
'utf8'
);
const received = Buffer.from(signatureHeader.trim(), 'utf8');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
const app = express();
app.post('/webhooks/expo', express.raw({ type: '*/*' }), (req, res) => {
const secret = process.env.EXPO_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500); // fail closed
if (!verifyExpoSignature(req.body, req.get('expo-signature'), secret)) {
return res.sendStatus(401);
}
const payload = JSON.parse(req.body.toString('utf8'));
const kind = 'buildDetailsPageUrl' in payload ? 'build' : 'submit';
// Handle payload.status for this kind, then acknowledge.
res.sendStatus(200);
});
The same check in Python:
import hashlib
import hmac
def verify_expo_signature(raw_body: bytes, signature_header, secret: str) -> bool:
if not signature_header or not secret:
return False
expected = "sha1=" + hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha1).hexdigest()
return hmac.compare_digest(signature_header.strip().encode("utf-8"), expected.encode("utf-8"))
# In FastAPI: raw_body = await request.body()
# verify_expo_signature(raw_body, request.headers.get("expo-signature"),
# os.environ["EXPO_WEBHOOK_SECRET"])
Expo's own sample reads the body with bodyParser.text({ type: '*/*' }) and compares with the safe-compare package. Reading the body as raw bytes, as above, does the same job without the extra dependency. To check a signature by hand, run printf '%s' "$BODY" | openssl dgst -sha1 -hmac "$EXPO_ and add the sha1= prefix.
Make Expo webhooks production-ready. Hookdeck Event Gateway verifies the
expo-signatureheader, answers EAS with an immediate200 OK, and durably queues every build and submit notification before your service sees it.
Expo webhook limitations and pain points
No event type in the payload
The Problem: A receiver that handles both webhooks cannot read the event type from the request.
Why It Happens: You pick BUILD or SUBMIT when you create the webhook, and Expo never sends it. No header or body field carries it.
Workarounds:
- Check for
buildDetailsPageUrl(build) orsubmissionDetailsPageUrl(submit). - Or give each webhook its own URL path, such as
/webhooks/expo/buildand/webhooks/expo/submit.
How Hookdeck Can Help: Hookdeck filters can match on the presence of a body field, so build and submit notifications can be routed to different destinations from one source.
Vague retry behaviour
The Problem: You cannot tell how long Expo will keep trying a failed delivery, or when it gives up.
Why It Happens: Expo says only that a status outside 200 to 399 is retried "a few more times with exponential back-off". No count, schedule or timeout is published, and nothing says what happens after the last attempt.
Workarounds:
- Respond quickly with a
2xxand process asynchronously. - After an outage, check recent builds and submissions with the EAS CLI or dashboard rather than assuming every notification arrived.
How Hookdeck Can Help: Hookdeck stores every notification it receives and retries delivery to your service on a schedule you configure, so the length of Expo's retry window stops mattering.
Redirects count as success
The Problem: A notification is marked as delivered but never reaches your handler.
Why It Happens: Expo treats any status from 200 to 399 as success. A URL that answers with a redirect, such as an HTTP to HTTPS hop or a trailing-slash rewrite, ends the delivery without your code running.
Workarounds:
- Register the exact final URL, with the right scheme and path.
- Test with a real build and confirm your handler logged it.
How Hookdeck Can Help: A Hookdeck source URL answers directly with a 200, and its request log shows each notification, so a misrouted webhook is visible straight away.
No replay protection or event id
The Problem: A captured request can be replayed and still verifies, and retries cannot be matched by an id.
Why It Happens: The signature covers only the body. Expo sends no timestamp and no delivery or event id.
Workarounds:
- Deduplicate on the job
idtogether withstatus. - Treat the webhook as a signal for high-impact actions, and confirm the job's state through EAS before acting.
How Hookdeck Can Help: Hookdeck's deduplication can drop repeated notifications based on the body fields you choose, such as id and status.
Best practices
Verify against the raw body
The signature covers the exact bytes Expo sent. Use express.raw() in Express, await request.text() in a Next.js route handler and await request.body() in FastAPI. Do not verify against re-serialised JSON.
Keep the sha1= prefix in the comparison
The header value includes sha1=. Either add the prefix to your computed digest, as above, or strip it from the header before comparing.
Use a long random secret
Expo requires at least 16 characters. Generate a long random value and rotate it with eas webhook:update if it leaks.
Deduplicate on id and status
Expo sends no delivery id, but each job has one id and reaches a final status once, so the pair identifies a notification. Store id and status together and skip pairs you have already processed. See our guide to webhook idempotency.
Treat optional fields as optional
artifacts.buildUrl, error, cancelingUserId and the parent ids appear only in some states. Read them with optional access.
Acknowledge fast and process asynchronously
Downloading an artifact or kicking off a test run can take a while. Verify, store the payload, return 200, and do the work afterwards. See why to process webhooks asynchronously.
Conclusion
Expo sends a signed POST when an EAS Build or Submit job finishes, errors or is canceled, with expo-signature set to sha1= and a hex HMAC-SHA1 of the raw body. Verify against the raw bytes, tell build and submit payloads apart by their fields, deduplicate on id and status, and register the exact final URL so a redirect never swallows a notification.
Hookdeck Event Gateway sits in front of your receiver, checks every expo-signature, and holds each notification until your service has processed it.
Get started with Hookdeck for free and handle Expo webhooks reliably in minutes.