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

```json
{
  "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

1. Generate a secret of at least 16 characters, for example with `openssl rand -hex 32`, and store it as `EXPO_WEBHOOK_SECRET`.
2. From the project directory, create a webhook for each event type you need:

```bash
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"

```

1. Repeat in every project you want notifications for. Webhooks are per project, so `@johndoe/awesomeApp` and `@johndoe/coolApp` each 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_ID`. `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.

```javascript
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:

```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_WEBHOOK_SECRET"` and add the `sha1=` prefix.

> **Make Expo webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `expo-signature` header, answers EAS with an immediate `200 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) or `submissionDetailsPageUrl` (submit).
* Or give each webhook its own URL path, such as `/webhooks/expo/build` and `/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 `2xx` and 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 `id` together with `status`.
* 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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## 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](https://hookdeck.com) sits in front of your receiver, checks every `expo-signature`, and holds each notification until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Expo webhooks reliably in minutes.