Skip to content

Webhooks

A webhook sends an HTTP request to a URL you choose when something happens to one of your builds — so a CI job, an internal dashboard or a Slack channel hears about a release without polling for it. Webhooks belong to your account (or your organization), not to a workflow, so every upload path — the dashboard, the CLI, the GitHub Action, the MCP server — reaches the same receivers.

Add a webhook

In the dashboard under Settings → Developer → Webhooks, or from a terminal or the API. The signing secret is shown once, when the webhook is created; rotating it shows the new one once.

Two ways to add one
# From a terminal (npx needs nothing installed)
npx @betadrop/cli webhooks add https://ci.example.com/hooks/betadrop
npx @betadrop/cli webhooks add https://hooks.slack.com/services/T000/B000/XXXX

# Or from the API
curl -X POST https://api.betadrop.app/api/webhooks \
  -H "Authorization: Bearer $BETADROP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://ci.example.com/hooks/betadrop","events":["build.published","build.expiring"]}'

Up to 10 per account, and 10 per organization. URLs must use https:// and resolve to a public address. Webhooks are available on every plan.

Events

Events BetaDrop sends to webhooks
EventWhen it fires
build.publishedA build finished uploading and its install link works. From every upload path. Carries standingLink as well when the publish pointed one at it.
standing_link.updatedA standing link was pointed at a build — by a publish or by hand, and including a rollback to a build it served before.
build.expiringThe build's install link stops working within 24 hours. Once per build per expiry date, so a renewal that moves the date earns a fresh one.
build.file_missingThe build's file could not be found in storage, so installs of it will fail until it is uploaded again. Once per loss.
tester.installedSomeone downloaded the build from its install link. Fires on every counted download, so it is off by default — turn it on for an endpoint that tallies, not for a chat channel. Carries testerCount: distinct testers on that app in the last 30 days.

A new webhook listens to everything except tester.installed unless you pass events. A build in an organization notifies that organization's webhooks and never the uploader's personal ones; a personal build never reaches an organization's.

What arrives

build.published, format json
POST /hooks/betadrop HTTP/1.1
Content-Type: application/json
User-Agent: BetaDrop-Webhooks/1.0 (+https://betadrop.app/docs/webhooks/)
BetaDrop-Event: build.published
BetaDrop-Delivery: 0b9d7c1e-6a51-4c7e-9a44-2f6d1c1f5b20
BetaDrop-Signature: t=1789567200,v1=5f0c…e41a

{
  "id": "8e2f4d0a-3b6c-4e0f-9a1d-7c5b2e8f1a34",
  "event": "build.published",
  "createdAt": "2026-09-16T12:00:00Z",
  "data": {
    "build": {
      "id": "9fdd1bfa-85bd-4521-a5c4-471870a931d6",
      "name": "Acme",
      "version": "2.4.0",
      "buildNumber": "119",
      "bundleId": "com.acme.app",
      "platform": "ios",
      "fileSize": 48213904,
      "source": "cli",
      "notes": "Fixes the login crash",
      "downloadCount": 0,
      "shortId": "jHfgwG",
      "installUrl": "https://betadrop.app/install/?i=jHfgwG",
      "createdAt": "2026-09-16T12:00:00Z",
      "expiresAt": "2026-09-19T12:00:00Z",
      "organizationId": null
    },
    "standingLink": {
      "id": "5aab444e-aab4-4e98-a305-79c6d8be43e5",
      "slug": "acmebeta",
      "label": "Acme Beta",
      "url": "https://betadrop.app/install/?i=acmebeta"
    }
  }
}

Every event has the same envelope: id identifies the occurrence and is the same across every endpoint it was sent to, event names it, and data carries it. Build events put the build under data.build in the shape above; standing_link.updated adds data.standingLink. Timestamps are UTC, RFC 3339. expiresAt is null for a build that does not expire.

Headers on every webhook request
HeaderMeaning
BetaDrop-EventThe event name, so a receiver can route before parsing the body.
BetaDrop-DeliveryThis delivery's id — the same on every retry of it. Delivery is at-least-once, so a request that timed out on your side can arrive again: dedupe on this header or on the body's id.
BetaDrop-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>. See below.
User-AgentBetaDrop-Webhooks/1.0

Verify the signature

v1 is the HMAC-SHA256, keyed with the webhook's secret, of the timestamp, a full stop, and the raw request body: <t>.<body>. The timestamp is inside the signed material, so check it is recent as well — that is what stops a captured request being replayed later.

Node.js
import crypto from "node:crypto";

// `rawBody` must be the bytes as received — parse the JSON only after this passes.
export function verifyBetaDrop(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const given = Buffer.from(parts.v1 ?? "", "hex");
  return given.length === 32 && crypto.timingSafeEqual(given, Buffer.from(expected, "hex"));
}
Python
import hashlib, hmac, time

def verify_betadrop(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts.get("t", "0"))
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

Compute it over the bytes you received, before any JSON parsing — re-serialising a parsed body changes whitespace and breaks the match.

Responses, retries and switching off

  • Success is any 2xx within 10 seconds. Redirects are not followed and count as a failure, so give the final URL.
  • A failure is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours — seven attempts over about a day and a half — then marked failed.
  • 15 failed deliveries in a row switch the webhook off, and whoever created it gets an email saying so. Failed deliveries are kept: switch it back on and redeliver anything you still need.
  • The delivery log keeps 30 days, with the status code and the first 200 characters of any error the receiver returned.

Slack and Discord

Paste an incoming webhook URL and BetaDrop recognises it — hooks.slack.com gets Slack's message format and discord.com/api/webhooks/… gets Discord's — so there is nothing to configure beyond the URL. The message names the build, its version and platform, and links the install page; release notes follow when the build has them.

What lands in the channel
New build: Acme 2.4.0 (119) for iOS — install link
Fixes the login crash
Also live on the standing link https://betadrop.app/install/?i=acmebeta

Chat formats carry no signature a chat service can check, so keep a chat webhook to the default events. To force a format on another URL, pass "format": "slack" or "discord".

Organizations

Webhooks added while working in an organization belong to it and receive every team build. Any member can see them and their delivery log; only owners and admins can add, change or remove them, because a webhook decides where the team's install links are sent. Using the API, send the organization id in the X-BetaDrop-Org header, or use a token created inside the organization.

Managing them from the API

Webhook endpoints
RequestWhat it does
GET /api/webhooksList webhooks, with the event catalogue.
POST /api/webhooksAdd one: url, optional events, format, description. Returns secret once.
PATCH /api/webhooks/{id}Change url, events, format, description or active.
DELETE /api/webhooks/{id}Remove it.
POST /api/webhooks/{id}/testSend a ping now and return the receiver's status. Not logged and never counts toward switching it off.
POST /api/webhooks/{id}/rotate-secretA new secret, returned once. The old one stops being used immediately.
GET /api/webhooks/{id}/deliveriesThe delivery log, newest first. ?limit= up to 200.
POST /api/webhooks/deliveries/{id}/redeliverQueue a delivery again, now.

Authentication and error shapes are the same as the rest of the HTTP API.