Webhooks
Webhooks deliver notification events as JSON POST requests to a URL you control. Configure them under Notifications → Channels → Webhook.
Envelope
Section titled “Envelope”{ "id": "evt_01m2…", "type": "cluster.regressed", "version": "2026-09-01", "createdAt": "2026-09-12T14:02:11Z", "orgId": "org_…", "projectId": "proj_…", "data": { "cluster": { "id": "cl_218", "kind": "error_click", "title": "Upload fails with 413 on iPad", "count24h": 143 }, "links": { "replay": "https://app.backstory.io/sessions/…?t=4230" } }}Event types: cluster.created, cluster.regressed, cluster.threshold, cluster.reopened, error.new, error.rate, release.regression, vitals.regression, monitor.trigger, ai.digest, ai.anomaly, ai.deep_analysis_ready, live.help_requested, live.control_started, usage.threshold, billing.event, privacy.leak_radar, security.break_glass, security.sso_changed, security.token_created, custom, and notification.test.
A custom webhook fires from a notification rule whose source is custom, matching
Backstory.track(name, props, { severity }). Configure the name and optional required
properties on the rule. See Custom events.
Delivery
Section titled “Delivery”Six attempts with exponential backoff over 24 hours. Respond with any 2xx within 10 seconds. Deliveries are visible and replayable under Notifications → Deliveries. Every notification includes the title, summary, severity, counts, route, release, and links. Payloads never contain masked content.
Signature
Section titled “Signature”Every request carries X-Backstory-Signature: t=<unix seconds>,v1=<hex> where v1 = HMAC-SHA256(secret, "<t>.<raw body>"). The secret is shown once when you create the channel (it starts with whsec_).
To verify a delivery:
- Read the raw request body as bytes. Verify before parsing JSON, since re-serialized JSON will not match.
- Split the header on
,. Taketand everyv1value. - Reject the request when
tis more than five minutes from the current time. - Compute
HMAC-SHA256(secret, t + "." + body)as lowercase hex. - Compare it to each
v1with a constant-time comparison. Accept when any one matches.
After Rotate secret the header carries two v1 values for 24 hours, one per secret, so a receiver that checks every v1 keeps working while you roll the new secret out.
Send yourself a notification.test event with Send test on the channel row to confirm the receiver.
Node.js
Section titled “Node.js”import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: Buffer or string of the unparsed request body.export function verify(rawBody, header, secret, toleranceSec = 300) { const parts = header.split(",").map((kv) => kv.trim().split("=")); const t = parts.find(([k]) => k === "t")?.[1]; const sigs = parts.filter(([k]) => k === "v1").map(([, v]) => v); if (!t || sigs.length === 0) return false; if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false; const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex"); return sigs.some((s) => s.length === expected.length && timingSafeEqual(Buffer.from(s), Buffer.from(expected)));}Express: register the route with express.raw({ type: "application/json" }) so req.body is the untouched bytes.
Python
Section titled “Python”import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool: parts = [kv.strip().split("=", 1) for kv in header.split(",")] t = next((v for k, v in parts if k == "t"), None) sigs = [v for k, v in parts if k == "v1"] if t is None or not sigs: return False if abs(time.time() - int(t)) > tolerance: return False expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, s) for s in sigs)Django: use request.body. Flask: request.get_data(). FastAPI: await request.body().
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "strconv" "strings" "time")
func verify(body []byte, header, secret string, tolerance time.Duration) bool { var t string var sigs []string for _, kv := range strings.Split(header, ",") { k, v, _ := strings.Cut(strings.TrimSpace(kv), "=") switch k { case "t": t = v case "v1": sigs = append(sigs, v) } } ts, err := strconv.ParseInt(t, 10, 64) if err != nil || len(sigs) == 0 { return false } if d := time.Since(time.Unix(ts, 0)); d > tolerance || d < -tolerance { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(t + ".")) mac.Write(body) expected := hex.EncodeToString(mac.Sum(nil)) for _, s := range sigs { if hmac.Equal([]byte(s), []byte(expected)) { return true } } return false}Read the body with io.ReadAll(r.Body) before decoding it.
using System.Security.Cryptography;using System.Text;
public static class BackstoryWebhook{ public static bool Verify(byte[] rawBody, string header, string secret, int toleranceSec = 300) { string? t = null; var sigs = new List<string>(); foreach (var kv in header.Split(',')) { var idx = kv.IndexOf('='); if (idx < 0) continue; var key = kv[..idx].Trim(); var value = kv[(idx + 1)..].Trim(); if (key == "t") t = value; else if (key == "v1") sigs.Add(value); } if (t is null || sigs.Count == 0 || !long.TryParse(t, out var ts)) return false; if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > toleranceSec) return false;
var payload = new byte[t.Length + 1 + rawBody.Length]; Encoding.UTF8.GetBytes(t + ".", 0, t.Length + 1, payload, 0); Buffer.BlockCopy(rawBody, 0, payload, t.Length + 1, rawBody.Length); var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), payload);
foreach (var sig in sigs) { byte[] given; try { given = Convert.FromHexString(sig); } catch (FormatException) { continue; } if (CryptographicOperations.FixedTimeEquals(given, expected)) return true; } return false; }}ASP.NET Core: call Request.EnableBuffering() and copy Request.Body to a MemoryStream, or add [FromBody] byte[] binding, so the bytes match what was sent.
import java.nio.charset.StandardCharsets;import java.security.MessageDigest;import java.time.Instant;import java.util.*;import javax.crypto.Mac;import javax.crypto.spec.SecretKeySpec;
static boolean verify(byte[] body, String header, String secret) throws Exception { String t = null; List<String> sigs = new ArrayList<>(); for (String kv : header.split(",")) { String[] p = kv.trim().split("=", 2); if (p.length != 2) continue; if (p[0].equals("t")) t = p[1]; else if (p[0].equals("v1")) sigs.add(p[1]); } if (t == null || sigs.isEmpty()) return false; if (Math.abs(Instant.now().getEpochSecond() - Long.parseLong(t)) > 300) return false; Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); mac.update((t + ".").getBytes(StandardCharsets.UTF_8)); byte[] expected = HexFormat.of().formatHex(mac.doFinal(body)).getBytes(StandardCharsets.UTF_8); for (String s : sigs) { if (MessageDigest.isEqual(s.getBytes(StandardCharsets.UTF_8), expected)) return true; } return false;}require "openssl"
def verify(raw_body, header, secret, tolerance: 300) pairs = header.split(",").map { |kv| kv.strip.split("=", 2) } t = pairs.find { |k, _| k == "t" }&.last sigs = pairs.select { |k, _| k == "v1" }.map(&:last) return false if t.nil? || sigs.empty? return false if (Time.now.to_i - t.to_i).abs > tolerance expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{raw_body}") sigs.any? { |s| s.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(s, expected) }endRails: use request.raw_post.
function verify(string $rawBody, string $header, string $secret, int $tolerance = 300): bool{ $t = null; $sigs = []; foreach (explode(',', $header) as $kv) { [$k, $v] = array_pad(explode('=', trim($kv), 2), 2, null); if ($k === 't') $t = $v; elseif ($k === 'v1') $sigs[] = $v; } if ($t === null || !$sigs) return false; if (abs(time() - (int) $t) > $tolerance) return false; $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret); foreach ($sigs as $s) { if (hash_equals($expected, $s)) return true; } return false;}Read the body with file_get_contents('php://input').
Testing
Section titled “Testing”Use the Send test button on a channel to receive a notification.test event, or POST /v1/notifications/channels/{id}/test.