Webhooks
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.
# 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
| Event | When it fires |
|---|---|
| build.published | A 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.updated | A standing link was pointed at a build — by a publish or by hand, and including a rollback to a build it served before. |
| build.expiring | The 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_missing | The build's file could not be found in storage, so installs of it will fail until it is uploaded again. Once per loss. |
| tester.installed | Someone 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
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.
| Header | Meaning |
|---|---|
| BetaDrop-Event | The event name, so a receiver can route before parsing the body. |
| BetaDrop-Delivery | This 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-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256>. See below. |
| User-Agent | BetaDrop-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.
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"));
}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.
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=acmebetaChat 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
| Request | What it does |
|---|---|
| GET /api/webhooks | List webhooks, with the event catalogue. |
| POST /api/webhooks | Add 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}/test | Send a ping now and return the receiver's status. Not logged and never counts toward switching it off. |
| POST /api/webhooks/{id}/rotate-secret | A new secret, returned once. The old one stops being used immediately. |
| GET /api/webhooks/{id}/deliveries | The delivery log, newest first. ?limit= up to 200. |
| POST /api/webhooks/deliveries/{id}/redeliver | Queue a delivery again, now. |
Authentication and error shapes are the same as the rest of the HTTP API.