Skip to content
You are reading the docs for Backstory 0.2 (private beta). Behavior may change before general availability; the changelog lists every change.

Webhooks

Webhooks deliver notification events as JSON POST requests to a URL you control. Configure them under Notifications → Channels → Webhook.

{
"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.

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.

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:

  1. Read the raw request body as bytes. Verify before parsing JSON, since re-serialized JSON will not match.
  2. Split the header on ,. Take t and every v1 value.
  3. Reject the request when t is more than five minutes from the current time.
  4. Compute HMAC-SHA256(secret, t + "." + body) as lowercase hex.
  5. Compare it to each v1 with 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.

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.

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

Rails: 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').

Use the Send test button on a channel to receive a notification.test event, or POST /v1/notifications/channels/{id}/test.