Receive release.created events as HTTPS POSTs to your endpoint, signed with HMAC-SHA256.
Self-serve (your account — no admin key):
- Web: Account → Notifications — Webhooks card (follows-scoped or org-scoped, delivery activity per subscription).
- CLI: after
releases login,releases webhook add --scope follows --url https://your.app/hookorreleases webhook add --org acme --url …. - API:
POST /v1/me/webhooks— see Self-serve subscriptions.
The signing key is shown once at create (and again after rotate-secret). Store it — you can't retrieve it later.
Admin-provisioned (operator root key, org-scoped under /v1/webhooks):
releases admin webhook add \
--org acme \
--url https://your.app/releases \
--description "production hook"Each event arrives as POST <your-url> with these headers:
Content-Type: application/json
X-Releases-Version: 1
X-Releases-Event-Id: evt_<id> # idempotency key
X-Releases-Timestamp: <unix-seconds>
X-Releases-Signature: sha256=<hex> # HMAC-SHA256(key, "${timestamp}.${body}")
User-Agent: releases-webhooks/1
Body: a JSON ReleaseEvent (see architecture/events.md).
Respond 2xx within 10 seconds to ack. Anything else triggers retry (5xx) or terminal failure (4xx).
Set format on the subscription to change the body:
format |
Body | Signed? |
|---|---|---|
json (default) |
Raw ReleaseEvent + X-Releases-* HMAC headers |
Yes |
slack |
Slack Block Kit card to a hooks.slack.com incoming webhook |
No — the URL is the secret |
discord |
Discord embed to a discord.com/api/webhooks/{id}/{token} endpoint |
No — the URL is the secret |
import crypto from "node:crypto";
function verify(secret, timestamp, body, signature, now = Math.floor(Date.now() / 1000)) {
if (!/^\d+$/.test(timestamp)) return false;
const ts = Number(timestamp);
if (!Number.isSafeInteger(ts) || Math.abs(now - ts) > 300) return false;
const expected =
"sha256=" +
crypto.createHmac("sha256", Buffer.from(secret, "hex")).update(`${ts}.${body}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import hmac, hashlib, time
def verify(secret, timestamp, body, signature, now=None):
if now is None:
now = int(time.time())
try:
ts = int(timestamp)
except (TypeError, ValueError):
return False
if abs(now - ts) > 300:
return False
expected = "sha256=" + hmac.new(
bytes.fromhex(secret),
f"{ts}.{body}".encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"time"
)
func verify(secret, timestamp, body, sig string) bool {
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
diff := time.Now().Unix() - ts
if diff < -300 || diff > 300 {
return false
}
key, err := hex.DecodeString(secret)
if err != nil {
return false
}
m := hmac.New(sha256.New, key)
m.Write([]byte(fmt.Sprintf("%d.%s", ts, body)))
expected := "sha256=" + hex.EncodeToString(m.Sum(nil))
return hmac.Equal([]byte(expected), []byte(sig))
}X-Releases-Timestamp is included in the signed input ("${timestamp}.${body}"), so the signature covers both the payload and the time it was issued. To prevent an attacker from capturing a valid request and replaying it later, your verifier should reject any request whose timestamp is more than ±5 minutes (300 seconds) from your server's wall clock — before performing the HMAC check. The reference snippets above already do this. A tighter window (e.g. 30 seconds) is fine if your clocks are well-synchronized.
Delivery is at-least-once, so the same event may arrive more than once (typically after a transient failure on your side). Use X-Releases-Event-Id as the dedup key and persist it in durable storage — e.g. a processed_event_ids table with a unique index, checked before you act on the event. An in-memory set or TTL cache is not sufficient: a process restart between delivery and ack will cause replays to be reprocessed.
Signed-in users manage webhooks at /v1/me/webhooks (browser session, user API key, or Sign in with Releases OAuth token), via the account notifications UI, the releases webhook CLI, or direct API calls. Surfaces: create/list/patch/delete, rotate-secret, test, and delivery history.
POST /v1/me/webhooks with orgId or orgSlug and optional filters: sourceId / sourceSlug, productId / productSlug, and releaseType (feature | rollup). Non-null filters are ANDed. Up to 10 org-scoped subscriptions per account. Follows-scoped subs accept releaseType only (org/product/source filters stay on the follow graph).
POST /v1/me/webhooks { "scope": "follows", "url": "…" } delivers release.created events for releases matching the caller's current user_follows graph — same semantics as GET /v1/me/feed (org follow covers all sources under that org; product follow matches releases from sources tied to that product). One follows-scoped subscription per account; it does not count against the 10 org-scoped cap. Follow/unfollow changes apply on the next publish (no snapshot). Scope cannot be converted via PATCH — delete and recreate.
Signed-in users can also create webhooks owned by a workspace (the Better Auth organization backing "Workspaces" — see workspaces.md) instead of their personal account, so a team's shared channel keeps receiving events even if the person who created the webhook leaves. Routes mirror the personal ones, scoped under /v1/workspaces/:workspaceId/webhooks: create/list/get/patch/delete, rotate-secret, test, and deliveries.
- Org-scoped only. There is no workspace "follows" scope — every workspace webhook takes
orgId/orgSlugand the same optionalsourceId/productId/releaseTypefilters as a personal org-scoped subscription.POSTwithscope: "follows"is rejected with400 bad_request. - Permissions. Workspace owners and admins can create, edit, rotate the signing key, and delete. Any member can list, view, test, and read delivery history. A non-member gets
404(existence isn't leaked); a member without manage rights gets403. - Cap. Up to 10 webhook subscriptions per workspace (separate from each member's personal 10).
- Cascade. Deleting the workspace deletes its webhooks. A member leaving the workspace does not affect them — the webhook stays owned by the workspace, not by any one person.
- Auto-pause notice. If a workspace webhook is auto-disabled after repeated delivery failures, every owner/admin of the workspace gets an email (not just whoever created it).
Webhook URLs must use HTTPS and must not target private or internal networks. Registration rejects:
localhost,.localhost,.local, and.internalhostnames- Private, link-local, and reserved IP ranges (RFC1918,
127.0.0.0/8,169.254.0.0/16, CGNAT, …) - Cloud metadata endpoints (e.g.
169.254.169.254) - Domain names that resolve (at registration time) to any private/reserved address
Use a publicly reachable HTTPS endpoint on the public internet.
POST /v1/me/webhooks/:id/test enqueues a real signed delivery. To prevent abuse, self-serve test sends are capped at 5 per minute per subscription and 20 per minute per account. Over-limit requests return 429 with Retry-After: 60.
Each delivery attempt is logged. Use it to debug a failing endpoint after a test or a real release.created.
Surfaces:
- Web: Account → Notifications → Webhooks → Activity on a subscription (last 15 attempts).
- CLI:
releases webhook deliveries <id>(or the last-10 block inreleases webhook show <id>). Admin:releases admin webhook deliveries <id>. - API:
GET /v1/me/webhooks/:id/deliveries(self-serve) orGET /v1/webhooks/:id/deliveries(admin). Query params:limit(1–100, default 20),failed=trueto omit successes.
Each row includes timestamp, event id, outcome (success | retry | perm_fail | dlq | auto_disabled | skipped), HTTP status, latency, attempt number, and a truncated error message on failure.
Aggregate health vs. history: subscription rows and list/detail responses also carry deliveryHealth, lastSuccessAt, and consecutiveFailures — a one-line summary for dashboards. The activity log is per-attempt detail; use it when health says "failing" but you need the status code or error body.
Indexing lag: the delivery log is eventually consistent. After POST …/test, wait ~20–30 seconds before expecting rows in Activity, deliveries, or the show last-10 table. Toggle Activity off/on in the account UI to reload. An empty table right after a successful enqueue is normal — retry once.
Retention: queryable history is retained for ~90 days. Longer retention would need a separate export path (#1508).
2xx→ ack, no retry.4xx→ no retry. Subscriber bug; fix and send a test (releases webhook test <id>orPOST …/test).5xx, network error, timeout → retried up to 6 times with exponential backoff (~2 hours total).- After 6 retries → message moves to the dead-letter queue. The subscription's
consecutive_failurescounter increments. After 50 consecutive failures the subscription is auto-disabled; re-enable from the account UI,releases webhook edit <id> --enable, orPATCH /v1/me/webhooks/:id.
GET https://api.releases.sh/v1/webhooks/events?since=<seq>&limit=<1-500>
Public endpoint — no authentication required.
Response:
{ "events": [...], "head": <current-seq>, "gap": { "oldestSeq": <n> } }gap is set when since is below what we still have buffered (~7 days). Backfill older events via GET /v1/releases/latest.
Follow and org webhooks above deliver every matching release.created event. Semantic alerts are a separate signed-in preference: a freeform description of what you care about, scored only against releases that already match your follows. When an alert matches and webhook delivery is on, the same signed release.created event is also sent to the alert's linked subscription, or to your follows-scoped subscription when none is linked. Operators can push synthetic releases through that same publish path with POST /v1/admin/semantic-alerts/preview. See architecture/semantic-alerts.md and #2304.
Verify signatures from a captured payload using the CLI:
releases webhook verify \
--secret <key> \
--signature <X-Releases-Signature header> \
--timestamp <X-Releases-Timestamp header> \
--body-file path/to/captured-body.jsonExit code 0 on match, 1 on mismatch.