Automating Expo app build delivery to QA with CircleCI and EAS webhooks
Software Engineer

Manually sharing mobile app builds with Quality Assurance (QA) engineers can be a tedious and error-prone process. Developers often find themselves exporting .apk
or .ipa
files, uploading them to Google Drive or Dropbox, and then pinging the QA team on Slack to announce the upload, all while juggling deadline and code reviews. This manual process not only slows down feedback cycles but also leaves room for human error, miscommunication, or outdated builds being tested.
In this article, you will learn how to automate that entire flow using Expo EAS, CircleCI, and a custom webhook server. After every successful build triggered in CI, the webhook server will receive a notification from EAS, download the build artifact, upload it to an S3 bucket, and send a Slack message with the public download link which makes it instantly accessible to the QA team.
Prerequisites
To follow along with this guide, you need to have the following:
- Node.js and npm installed
- Expo CLI installed
- EAS CLI installed and configured
- A Expo account
- A AWS account and its associated access and secret keys
- AWS CLI installed and configured
- A Slack workspace where you can create an app and webhook
- ngrok installed for exposing your local web server
- CircleCI and GitHub accounts
Set up the Expo project
This tutorial’s focus is on the build delivery process. You will use a functional Expo app with some basic features and tests already set up for you. The app is a simple task tracker built using Expo, TypeScript, and React Native. It allows users to add, complete, and delete tasks. This gives you just enough functionality to demonstrate real-world testing and automation without too much complexity.
To clone the project to your local machine, execute:
git clone --single-branch -b starter-template https://github.com/CIRCLECI-GWP/circleci-expo-build-delivery.git
cd circleci-expo-build-delivery/task-tracker
Note The project is organized as a monorepo, with the mobile app in the task-tracker
directory.
Go to the task-tracker
folder. Install the dependencies required to run the app and tests:
npm install
Make sure everything is working as expected by launching the app:
npx expo start
You can use an emulator or a physical device (with the Expo Go app) to verify that the task tracker works as expected.
Before integrating CircleCI for automation, make sure all the tests run locally:
npm run test
There should be output showing that all the tests have run successfully. The tests cover app components such as task input, listing, deleting, and toggling completion. They are defined in the task-tracker/__tests__
folder:
> task-tracker@1.0.0 test
> jest
PASS __tests__/components/TaskInput.test.tsx (13.954 s)
PASS __tests__/components/TaskItem.test.tsx (13.985 s)
PASS __tests__/components/TaskList.test.tsx (14.175 s)
PASS __tests__/TasksSecreen.test.tsx
Test Suites: 4 passed, 4 total
Tests: 14 passed, 14 total
Snapshots: 0 total
Time: 15.776 s
Ran all test suites.
Set Up AWS S3 and Slack
Before you set up the webhook server, you need to configure the services that it will interact with:
- AWS S3 to store build artifacts.
- Slack to notify the QA team with download links.
Start by creating a S3 bucket that is publicly accessible for read-only access. This ensures that your QA team can download the build artifacts directly via a link:
aws s3api create-bucket --bucket <YOUR-UNIQUE-BUCKET-NAME>
Note: Make sure to replace the <YOUR-UNIQUE-BUCKET-NAME>
placeholder with a globally unique bucket name. Also, if you’re creating the bucket in a region outside us-east-1
, add the flag --create-bucket-configuration LocationConstraint=<YOUR-AWS-REGION>
, and replace the placeholder with the correct value.
AWS blocks public access by default. You need to turn that off:
aws s3api put-public-access-block --bucket <YOUR-UNIQUE-BUCKET-NAME> --public-access-block-configuration BlockPublicAcls=false,IgnorePublicAcls=false,BlockPublicPolicy=false,RestrictPublicBuckets=false
Next, add a read-only policy to the bucket:
aws s3api put-bucket-policy --bucket <YOUR-UNIQUE-BUCKET-NAME> --policy '{"Version":"2012-10-17","Statement":[{"Sid":"PublicReadAccess","Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::<YOUR-UNIQUE-BUCKET-NAME>/*"}]}'
Now, any file uploaded to this bucket will be publicly downloadable via the bucket’s public URL.
You can now create a Slack app. Open your Slack apps page and select Create New App.
For the “Create an app” prompt, select From scratch.
Provide the app name, choose a workspace, and select Create App.
On the app details page, select Incoming Webhooks under the “Features” section. Activate incoming webhooks, and click the Add New Webhook button.
Next, select the channel where the app should post. Click Allow:
Once the webhook is created, copy its URL. You will need it in the next section of this tutorial.
Set up a local webhook server
To complete the automation loop, you’ll create a simple Express server that listens for webhook events from EAS. When a build completes, EAS will send a POST
request to this server with build metadata, including a download URL for the artifact. The server will download the build artifact, upload it to AWS S3, and send a Slack message with a public download link.
In the project root directory, there is a webhook-server
directory. This directory contains a simple Express server with a single endpoint defined in the src/app.ts
file. To add the webhook functionality to this server, start by installing the required dependencies:
cd ..
cd webhook-server
npm i axios aws-sdk @slack/webhook safe-compare
npm i -D @types/safe-compare
Here is how you will use each of these dependencies:
axios
: To download the build artifactaws-sdk
: To upload the build artifact to AWS S3@slack/webhook
: To send a message to Slacksafe-compare
: To verify the signature of the EAS webhook request
Next, rename the .env.example
file to .env
. Replace the placeholders for AWS_ACCESS_KEY_ID
, AWS_SECRET_ACCESS_KEY
, and AWS_REGION
with your actual AWS credentials. Make sure to also replace AWS_S3_BUCKET_NAME
and SLACK_WEBHOOK_URL
with the correct values from the previous section of this tutorial.
You can start working on the utility functions that your webhook server will use. Open the src/utils/index.ts
file and add this code:
import crypto from "crypto";
import { Request } from "express";
import safeCompare from "safe-compare";
import { EASWebhookData } from "../types";
import axios from "axios";
import AWS from "aws-sdk";
import { IncomingWebhook } from "@slack/webhook";
const EAS_WEBHOOK_SECRET = process.env.EAS_WEBHOOK_SECRET!;
const S3_BUCKET_NAME = process.env.AWS_S3_BUCKET_NAME!;
const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL!;
// AWS S3 Configuration
const s3 = new AWS.S3({
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
export async function verifySignature(req: Request): Promise<boolean> {
const expoSignature = req.headers["expo-signature"] as string;
const webhookData: EASWebhookData = req.body;
const hmac = crypto.createHmac("sha1", EAS_WEBHOOK_SECRET);
hmac.update(JSON.stringify(webhookData));
const hash = `sha1=${hmac.digest("hex")}`;
// Use safeCompare to prevent timing attacks
return safeCompare(expoSignature, hash);
}
async function downloadFile(url: string): Promise<Buffer> {
const response = await axios.get(url, {
responseType: "arraybuffer",
timeout: 300000, // 5 minutes timeout
maxContentLength: 500 * 1024 * 1024, // 500MB max
});
return Buffer.from(response.data);
}
// Upload build artifacts to S3
async function uploadToS3(buffer: Buffer, key: string) {
const params = {
Bucket: S3_BUCKET_NAME,
Key: key,
Body: buffer,
ContentType: "application/octet-stream",
};
const result = await s3.upload(params).promise();
return result.Location;
}
// Send Slack notification
async function sendSlackNotification(s3Url: string, platform: string) {
const webhook = new IncomingWebhook(SLACK_WEBHOOK_URL);
await webhook.send({
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `🚀 *New ${platform} build is ready!*\nDownload: <${s3Url}|Click here>`,
},
},
],
});
}
// Process the build
export async function processBuild(webhookData: EASWebhookData) {
try {
if (webhookData.artifacts?.buildUrl) {
console.log(`Processing successful build: ${webhookData.id}`);
// Download the artifact
console.log(
"Downloading artifact from:",
webhookData.artifacts?.buildUrl
);
const artifactBuffer = await downloadFile(
webhookData.artifacts?.buildUrl
);
// Generate S3 key
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const fileExt = `${
webhookData.artifacts.buildUrl.split(".")[
webhookData.artifacts.buildUrl.split(".").length - 1
]
}`;
const s3Key = `builds/${webhookData.projectName}/${webhookData.platform}/${timestamp}.${fileExt}`;
// Upload to S3
console.log("Uploading to S3 with key:", s3Key);
const s3Url = await uploadToS3(artifactBuffer, s3Key);
console.log("Upload successful, S3 URL:", s3Url);
// Send Slack notification
console.log("Sending Slack notification...");
await sendSlackNotification(s3Url, webhookData.platform);
console.log("Slack notification sent successfully.");
}
} catch (error) {
console.error("Error processing successful build:", error);
// Log the error but don't throw - this runs in background
}
}
The code provides these methods:
verifySignature()
: Validates the webhook signature using HMAC-SHA1 and a shared secret to confirm that the request came from EAS. It usessafeCompare
to prevent timing attacks when comparing the computed hash with the incoming signature.downloadFile()
: Fetches the binary content of the build artifact from the given URL using Axios. The response is returned as a Buffer for further processing or uploading.uploadToS3()
: Uploads the downloaded artifact to AWS S3 using the provided buffer and object key. The file is saved with a generic application/octet-stream content type, and the function returns the S3 URL where the file can be accessed.sendSlackNotification()
: Sends a formatted Slack message using Slack’s Webhook API, including a button link to the newly uploaded artifact.processBuild()
: Orchestrates the entire process.
The processBuild()
method:
- Logs and downloads the artifact from EAS.
- Generates a unique S3 key based on the project name, platform, and timestamp.
- Uploads the artifact to S3.
- Sends a Slack message with the public S3 URL.
- Wraps all steps in a try-catch block to handle errors gracefully.
Open the src/app.ts
file and replace the GET /webhook
method with this:
app.post("/webhook", async (req: Request, res: Response) => {
try {
// Verify the webhook signature
if (!verifySignature(req)) {
console.error("Webhook signature verification failed");
res.status(401).send("Signatures didn't match!");
} else {
// Signature verification passed - return 200 immediately
console.log("Webhook signature verified successfully");
res.send("OK!");
// Process the build asynchronously without affecting the response
const webhookData: EASWebhookData = req.body;
if (
webhookData.status === "finished" &&
webhookData.artifacts?.buildUrl
) {
// Don't await this - let it run in background
processBuild(webhookData).catch((error) => {
console.error("Background build processing failed:", error);
});
} else {
// Log non-successful builds for monitoring
let message = `Build ${webhookData.id} status: ${webhookData.status}`;
if (webhookData.status === "errored" && webhookData.error) {
message += ` - Error: ${webhookData.error.message}`;
console.log("Build failed:", webhookData.error);
} else if (webhookData.status === "canceled") {
message += " - Build was canceled";
console.log("Build was canceled");
} else if (
webhookData.status === "finished" &&
!webhookData.artifacts?.buildUrl
) {
message += " - No build artifacts available";
console.log("Build finished but no artifacts available");
}
console.log(message);
}
}
} catch (error) {
console.error("Webhook processing error:", error);
res.status(500).json({
status: "error",
message: "Internal server error",
error: error instanceof Error ? error.message : "Unknown error",
});
}
});
This code defines an endpoint that receives build status events from Expo’s EAS service. When a POST
request hits this endpoint, it first verifies the signature using the verifySignature()
function. This ensures the request genuinely came from EAS.
- If verification fails, it logs an error and returns a
401 Unauthorized
response. - If the signature is valid, it immediately responds with a
200 OK
to avoid blocking EAS, then proceeds to handle the webhook asynchronously in the background.
If the webhook payload indicates the build was successful (status === 'finished'
) and a build artifact URL is present, it calls processBuild()
to handle downloading the build, uploading it to S3, and notifying Slack. This is done without await
, so it doesn’t delay the webhook response. For other statuses like errored
, canceled
, or finished
without artifacts, the handler logs informative messages to help in monitoring and debugging.
Remember to add these import statements to the same file:
import { processBuild, verifySignature } from "./utils";
import { EASWebhookData } from "./types";
Run the webhook server by executing this command in the terminal:
npm run dev
To receive webhook events from EAS, your webhook server must be publicly accessible. You will use ngrok for this. In a separate terminal, execute this command:
ngrok http 3000
Your output should be similar to this:
ngrok (Ctrl+C to quit)
🏃➡️ The Go SDK has reached v2—now simpler, more streamlined, and way more powerful: https://ngrok.com/r/go-sdk-v2
Session Status online
Account <YOR-EMAIL-ADDRESS> (Plan: Free)
Update update available (version 3.23.2, Ctrl-U to update)
Version 3.22.0
Region <YOUR-REGION>
Latency 164ms
Web Interface http://127.0.0.1:4040
Forwarding <YOUR-FORWARDING-URL> -> http://localhost:3000
Connections ttl opn rt1 rt5 p50 p90
0 0 0.00 0.00 0.00 0.00
Take note of your forwarding URL. You will need to use it in the next section of this tutorial.
Keep ngrok and your webhook server running.
Initialize the project on EAS with a local build
Before you can trigger builds from CI using EAS, your project must be initialized with Expo Application Services (EAS). This step ensures that:
- The project is registered with Expo.
- Build credentials are configured.
- The
eas.json
file is created.
You’ll also create a webhook so EAS can notify your server when builds are completed.
Start by opening the task-tracker
folder:
cd task-tracker
Next, configure your project for EAS build:
eas build:configure
You will be presented with two prompts:
- Would you like to automatically create an EAS project for @
/task-tracker? **Select Y** - Which platforms would you like to configure for EAS Build? Select All
Your project is almost ready for the first build, but you need to register a webhook for the project:
eas webhook:create
You will be presented with three prompts:
- Webhook event type: Select Build.
- Webhook URL: Provide your ngrok URL and append
/webhook
:https://c4f7-102-219-208-154.ngrok-free.app/webhook
. - Webhook secret: Provide a random string that is at least 16 characters long. You can use the command
node -e "console.log(crypto.randomBytes(20).toString('base64').replace(/[^a-zA-Z0-9]/g, '').slice(0, 20))"
to generate one from your terminal.
Note: Make sure to copy the value of your webhook token and provide it as the value in the webhook server’s .env
file for the EAS_WEBHOOK_SECRET
key. You can also restart the webhook server to load the new environment variable.
You can now create your first build. For this tutorial project, you will build the Android APK. If you’d like, you can adjust the command to create an artifact for IOS:
eas build --platform android --profile preview
You will be presented with two prompts:
- What would you like your Android application id to be? Click Enter to use the suggested ID or provide the one you want
- Generate a new Android Keystore? Select Yes
Your build will be put in a queue and you will be notified in the CLI once it is ready.
When your webhook receives the build event, a log will confirm that everything works as expected:
Webhook signature verified successfully
Processing successful build: <YOUR-BUILD-ID>
Downloading artifact from: https://expo.dev/artifacts/eas/<YOUR-ARTIFACT-NAME>.apk
Uploading to S3 with key: builds/task-tracker/android/2025-06-21T08-28-59-765Z.apk
Upload successful, S3 URL: https://<YOUR-S3-BUCKET>.s3.amazonaws.com/builds/task-tracker/android/2025-06-21T08-28-59-765Z.apk
Sending Slack notification...
Slack notification sent successfully.
You should get the Slack notification.
Automating the build process with CircleCI
The final step is to automate the entire workflow. Start by creating a new file in the project root folder named .circleci/config.yml
. Add this code:
version: 2.1
executors:
default:
docker:
- image: cimg/node:lts
working_directory: ~/repo/task-tracker
jobs:
run-tests-and-build:
executor: default
steps:
- checkout:
path: ~/repo
- run:
name: Install dependencies
command: npm ci
- run:
name: Run tests
command: npm run test
- run:
name: Trigger EAS build
command: npx eas-cli build --platform android --profile preview --non-interactive --no-wait
workflows:
build-app:
jobs:
- run-tests-and-build
This CircleCI configuration sets up a simple workflow to automate the testing and building of the Expo app. It defines a Docker environment using a Node.js image and sets the working directory to the task-tracker
folder inside the repository.
The single job, run-tests-and-build
, does four things in sequence:
- Checks out the project code.
- Installs dependencies using
npm ci
. - Runs tests.
- Triggers an Expo build for Android using the
eas-cli
.
The --non-interactive --no-wait
flags allow the build to start without waiting for it to complete. This saves you from being charged for extra CI minutes.
You are ready to upload your project to GitHub and create a project on CircleCI.
Before you can trigger the pipeline, you need to set the EXPO_TOKEN
environment variable. Create a personal access token on EAS. Add it as an environment variable to your CircleCI project using EXPO_TOKEN
as the key and token as the value.
You can now trigger the pipeline manually.
You now have a complete CI/CD pipeline for delivering Expo builds to QA. No more manual steps!
You can access the full project on GitHub.
Conclusion
In this tutorial, you learned how to automate the delivery of an Expo app build to QA using CircleCI, EAS, and a custom webhook server. You:
- Set up a functional Expo app with tests
- Built a webhook server that listens for EAS build events, uploads build artifacts to S3, and sends Slack notifications
- Initialized the project on EAS and created a webhook
- Configured CircleCI to run tests and trigger builds automatically on every push
With this setup in place, your QA team can receive new builds without any manual effort, saving time, reducing errors, and speeding up feedback cycles. Try extending this workflow further by adding support for iOS builds or automating uploads to app stores.