# Guide to Bitrise Webhooks: Features and Best Practices

Bitrise outgoing webhooks tell other systems what is happening in your mobile CI/CD pipeline: a build has started, a build has finished, a version has been uploaded to TestFlight, an App Store review has been approved. If you post build results to a chat tool, drive an in-house release dashboard, or kick off downstream steps when a store submission changes state, webhooks let those services hear about it without polling the Bitrise API.

This guide covers the two families of Bitrise webhooks, what Bitrise does and doesn't document about their payloads, how to authenticate requests that Bitrise doesn't sign, and the best practices for a production receiver.

## What are Bitrise webhooks?

Bitrise sends HTTP `POST` requests to a URL you register. There are two families, configured in different places:

* **CI build events.** An outgoing webhook on a project fires when a build is started and when it ends. You can pick **Send me everything** or **Select individual events**. Bitrise says that, for now, only build events are supported here.
* **Release Management events.** Release Management, Bitrise's app-store release product, reuses the same outgoing webhook configurations. You attach a webhook to a release or a release preset and choose which release automation events trigger it. Connecting webhooks in a Release Management project needs a standard license.

Neither family is signed. The only authentication is a custom header you add to the webhook, which Bitrise suggests for tracking requests "with API keys set as a custom header". Your receiver compares that header against the value you stored.

## Bitrise webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Project settings > Integrations > Webhooks > Outgoing Webhooks**, or `POST /apps/{app-slug}/outgoing-webhooks` |
| Permissions | Project **Admin**, or a custom role with **Edit workflows** |
| API event values | `all`, `build` and `pipeline` |
| CI build events | Build started and build ended. No body schema or type field is published |
| Release Management events | 68 documented `trigger_event` values, chosen per release or release preset |
| Body format | JSON |
| Authentication | Custom headers you define. Values can be hidden after saving |
| Signature | None |
| Deliveries | **Recent deliveries** shows each request, response and status code, with manual redelivery |
| Retries and timeout | Not documented |
| Source IPs | Not published for webhook delivery |

## Common events

Release Management webhooks carry the event name in `trigger_event`. Bitrise documents 68 of them. Some of the ones release tooling usually needs:

| `trigger_event` | Fires when |
| --- | --- |
| `app_version_added` | An app version is added |
| `release_candidate_build_selected` | A build is selected as the release candidate |
| `testflight_upload_completed` | A TestFlight upload completes |
| `testflight_upload_failed` | A TestFlight upload fails |
| `testflight_app_review_approved` | A TestFlight app review is approved |
| `google_play_upload_completed` | A Google Play upload completes |
| `google_play_upload_failed` | A Google Play upload fails |
| `approval_task_completed` | An approval task is completed |
| `app_store_review_rejected` | An App Store review is rejected |
| `app_store_release_completed` | An App Store release completes |
| `google_play_release_completed` | A Google Play release completes |

Watch for the one irregular spelling: `auto-upload_enabled` and `auto-upload_disabled` use a hyphen where every other name uses underscores.

Every Release Management body contains these common fields, plus fields that depend on the event:

| Field | Notes |
| --- | --- |
| `trigger_event` | The event name |
| `app_id`, `project_id`, `workspace_id` | Identifiers |
| `release_id`, `release_candidate_artifact_id`, `store_app_id` | Release identifiers |
| `triggered_by` | A user slug, or `Automation`, `Apple App Store` or `Google Play Store` |
| `triggered_at`, `sent_at` | Nanosecond-precision UNIX timestamps |
| `webhook_config_id` | The outgoing webhook configuration |
| `webhook_event_unique_id` | `WEBHOOK_ID@TRIGGER_TIMESTAMP_NANO` |

Upload, review and release-candidate events add `file_name`, `version` and `version_code`. `app_store_review_rejected` also adds `reason`. TestFlight review and distribution events add `testflight_beta_build_id`. Bitrise documents these as field tables only, with no example body, so check the nesting of the extra fields against a real delivery.

CI build webhooks are less well documented. Bitrise describes a build event as a build being started or ending, and publishes no body, no event type field and no Bitrise-set headers for them. Open **Recent deliveries** on the webhook to see exactly what your project sends, and tell the two families apart by the presence of `trigger_event`.

> **Inspect Bitrise webhooks as they arrive.** Point Bitrise at a [Hookdeck Inspect](https://inspect.hookdeck.com) URL to inspect and replay real deliveries, with no account or setup required. It's a quick way to see the CI build body that Bitrise doesn't document.

## Setting up Bitrise webhooks

Pick a header name and a long random value first (`openssl rand -hex 32` works), and store the value in your app's environment.

1. Open your project on Bitrise as an Admin, or with a custom role that has **Edit workflows**.
2. Click **Project settings**, select **Integrations**, then the **Webhooks** tab.
3. Scroll to **Outgoing Webhooks** and click **Add webhook**.
4. Enter your HTTPS endpoint in **URL**.
5. Choose **Send me everything** or **Select individual events**.
6. In **Headers**, enter your header name (for example `X-Webhook-Secret`) and the secret value, then click **Add header**.
7. Click **Create webhook**.

If you tick **Hide** next to the header, you can't view or change its value again, so keep your own copy before saving.

The API equivalent takes the same headers as a map:

```bash
curl -X POST "https://api.bitrise.io/v0.1/apps/APP-SLUG/outgoing-webhooks" \
  -H "Authorization: ACCESS-TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"events": ["build"], "url": "https://example.com/webhooks/bitrise", "headers": {"X-Webhook-Secret": "your-long-random-secret"}}'

```

When you update a webhook through the API, send every mandatory field again, including `events`, even if you're only changing the URL.

For Release Management, create the outgoing webhook as above first. Then open **Release Management**, select your app and a release that hasn't finished (or **Release presets** for a template), go to **Configuration**, open the **Outgoing webhooks** card with **Show details**, click **Add webhook**, choose the configuration from **Webhook configuration**, and select the events.

For local development, use the [Hookdeck CLI](/docs/cli): `hookdeck listen 3000 bitrise --path /webhooks/bitrise` gives you a public HTTPS URL that forwards to your local server, plus a web UI for inspecting and replaying deliveries, with no account required. Use the generated URL as the webhook URL, then start a build.

**With Hookdeck.** Create a Bitrise source in Hookdeck, enter the **Header Name** and the **Header Value** exactly as you set them on the webhook, including any `Bearer ` prefix, and use the source URL as the webhook URL.

## Securing Bitrise webhooks

Authenticating a Bitrise request means comparing your custom header against the stored value. Look the header up case-insensitively, compare the whole value (prefix included) in constant time, and check lengths before calling `crypto.timingSafeEqual`, which throws on buffers of different lengths.

```javascript
const crypto = require('crypto');
const express = require('express');

const app = express();

function safeEqual(a, b) {
  const x = Buffer.from(String(a));
  const y = Buffer.from(String(b));
  return x.length === y.length && crypto.timingSafeEqual(x, y);
}

function verifyBitriseRequest(headers) {
  const secret = process.env.BITRISE_WEBHOOK_SECRET;
  if (!secret) return false; // fail closed when unconfigured
  const name = (process.env.BITRISE_WEBHOOK_HEADER || 'X-Webhook-Secret').toLowerCase();
  const received = headers[name];
  return typeof received === 'string' && safeEqual(received, secret);
}

app.post('/webhooks/bitrise', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifyBitriseRequest(req.headers)) {
    return res.sendStatus(401);
  }

  let body;
  try {
    body = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  res.sendStatus(200);

  if (typeof body.trigger_event === 'string') {
    // Release Management: route on body.trigger_event,
    // deduplicate on body.webhook_event_unique_id
  } else {
    // CI build event: no documented type field, so inspect the body
  }
});

```

The same check in Python:

```python
import hmac
import os

def verify_bitrise_request(headers) -> bool:
    secret = os.environ.get("BITRISE_WEBHOOK_SECRET")
    if not secret:
        return False
    name = os.environ.get("BITRISE_WEBHOOK_HEADER", "X-Webhook-Secret")
    received = headers.get(name)  # case-insensitive in Starlette and Flask
    if not received:
        return False
    return hmac.compare_digest(received.encode("utf-8"), secret.encode("utf-8"))

```

A static header proves the sender knows the secret, but not that the body is unchanged, and it offers no replay protection: nothing is signed and Bitrise documents no timestamp header. Serve the endpoint over HTTPS only, keep the secret out of logs and source control, and rotate it if it might have leaked. Bitrise publishes no webhook egress IPs, so IP allowlisting isn't an option either. For services behind a firewall, Bitrise recommends making the request from inside the build instead.

> **Make Bitrise webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks your secret header on every request, durably queues each build and release event, and adds automatic retries and replay on top of Bitrise's manual redelivery.

## Bitrise webhook limitations and pain points

### No signature on any request

**The Problem:** Bitrise requests carry no signature. A custom header you invented is the only thing separating a real build notification from a forged one.

**Why It Happens:** Bitrise outgoing webhooks offer custom headers and nothing else for authentication.

**Workarounds:**

* Use a long random header value and compare it in constant time.
* Rotate the value by editing the webhook, then update your receiver.
* Treat the payload as a notification and confirm anything important through the Bitrise API.

**How Hookdeck Can Help:** Hookdeck's Bitrise source compares the header you name against the exact value you enter. A request without the right header still gets a 200 response, but Hookdeck marks it as failed verification and creates no event, so it never reaches your destination. Every request is logged with its full headers and body.

### Undocumented build payloads

**The Problem:** You can't write a CI build handler from the docs alone, because Bitrise publishes no body or type field for build started and build finished events.

**Why It Happens:** The Release Management page documents its fields as tables, but the CI pages only describe when build events fire.

**Workarounds:**

* Capture a real delivery from **Recent deliveries** and build your parser from it.
* Branch on the presence of `trigger_event` to separate Release Management events from build events.
* Read fields defensively and log bodies you don't recognise.

**How Hookdeck Can Help:** Hookdeck stores every request with its headers and body, so you can capture a build-started and build-finished pair once and replay them against your handler while you develop it.

### Recovery is manual

**The Problem:** Bitrise doesn't document automatic retries or a response timeout. If your endpoint is down, you may only find out by checking **Recent deliveries** and clicking redeliver.

**Why It Happens:** The deliveries view records the status code of each attempt and lets you resend it, but the docs say nothing about what Bitrise does on its own after a failure.

**Workarounds:**

* Return `200` quickly so slow processing doesn't count as a failure.
* Check **Recent deliveries** after any outage and redeliver what failed.
* Reconcile build state from the Bitrise API for anything critical.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as soon as it arrives and stores it, then delivers to your service with [automatic retries](/docs/retries) on a schedule you control. [Issues](/docs/issues) alert you when deliveries fail, and any stored event can be replayed.

### Duplicates from redelivery

**The Problem:** A manual redelivery, or any retry Bitrise makes, sends the same event again.

**Why It Happens:** Redelivery resends the original payload, and nothing in the request marks it as a repeat.

**Workarounds:**

* For Release Management, deduplicate on `webhook_event_unique_id`, which Bitrise builds from the webhook ID and the trigger timestamp.
* For build events, derive a key from fields you see in a captured delivery.

**How Hookdeck Can Help:** Hookdeck [deduplication](/docs/deduplication) can drop repeated deliveries based on a field such as `webhook_event_unique_id` before they reach your handler.

## Best practices

### Keep a copy of hidden header values

A hidden header can't be viewed or edited later. Store the value in your secrets manager before you tick **Hide**, or you'll have to replace the header to rotate it.

### Route on `trigger_event`, and expect two shapes

Release Management bodies have `trigger_event`. Build bodies don't have a documented type field. Check for `trigger_event` first, then handle build events as a separate shape.

### Watch the timestamp units

`triggered_at` and `sent_at` are UNIX timestamps with nanosecond precision. Divide by 1,000,000 before passing them to JavaScript's `Date`, and use a type that won't lose precision if you store them.

### Acknowledge fast and process asynchronously

Return `200` as soon as the header checks out and hand the work to a queue. Bitrise doesn't document its timeout, so don't depend on it being generous. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Make handlers idempotent

Store `webhook_event_unique_id` before you act and skip repeats. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

Bitrise sends unsigned JSON `POST` requests for CI build events and, on standard-license projects, for 68 Release Management events identified by `trigger_event`. Authenticate them with a custom header compared in constant time over HTTPS, capture a real build delivery before relying on its fields, and deduplicate Release Management events on `webhook_event_unique_id`.

Because Bitrise documents no automatic retry policy, [Hookdeck Event Gateway](/event-gateway) is a useful buffer: it checks the header, stores every event durably, retries delivery to your service and lets you replay anything that failed.

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