Skip to content

Latest commit

 

History

History
227 lines (161 loc) · 11.7 KB

webhooks.md

File metadata and controls

227 lines (161 loc) · 11.7 KB

Webhooks

Receive release.created events as HTTPS POSTs to your endpoint, signed with HMAC-SHA256.

Quickstart

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/hook or releases 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"

Delivery format

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

Verifying signatures

Node.js

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);
}

Python

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)

Go

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))
}

Replay protection

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.

Idempotency

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.

Self-serve subscriptions

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.

Org-scoped (default)

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).

Follows-scoped

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.

Workspace webhooks

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/orgSlug and the same optional sourceId/productId/releaseType filters as a personal org-scoped subscription. POST with scope: "follows" is rejected with 400 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 gets 403.
  • 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).

URL requirements

Webhook URLs must use HTTPS and must not target private or internal networks. Registration rejects:

  • localhost, .localhost, .local, and .internal hostnames
  • 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.

Test endpoint limits

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.

Delivery activity

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 in releases webhook show <id>). Admin: releases admin webhook deliveries <id>.
  • API: GET /v1/me/webhooks/:id/deliveries (self-serve) or GET /v1/webhooks/:id/deliveries (admin). Query params: limit (1–100, default 20), failed=true to 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).

Retry behavior

  • 2xx → ack, no retry.
  • 4xx → no retry. Subscriber bug; fix and send a test (releases webhook test <id> or POST …/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_failures counter increments. After 50 consecutive failures the subscription is auto-disabled; re-enable from the account UI, releases webhook edit <id> --enable, or PATCH /v1/me/webhooks/:id.

Replay

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.

Semantic alerts

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.

Local debugging

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.json

Exit code 0 on match, 1 on mismatch.