TutorialsOct 8, 202510 min read

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

Kevin Kimani

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:

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.

Creating a new Slack app

For the “Create an app” prompt, select From scratch.

Creating an app from scratch

Provide the app name, choose a workspace, and select Create App.

Providing app name and choosing workspace

On the app details page, select Incoming Webhooks under the “Features” section. Activate incoming webhooks, and click the Add New Webhook button.

Activate incoming webhooks

Next, select the channel where the app should post. Click Allow:

Selecting channel

Once the webhook is created, copy its URL. You will need it in the next section of this tutorial.

Copying webhook URL

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 artifact
  • aws-sdk: To upload the build artifact to AWS S3
  • @slack/webhook: To send a message to Slack
  • safe-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 uses safeCompare 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.

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:

  1. Checks out the project code.
  2. Installs dependencies using npm ci.
  3. Runs tests.
  4. 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.

Successful pipeline execution

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.