Webhooks
What we send when something happens to a secret, and how to know it was us.
What arrives
A JSON body with the event type, when it happened, the secret's id and how many views are left. There is no content field and never will be: we do not have the content, so a webhook could not carry it (REQ-NOTIFY-002). Events: secret.created, viewed, denied, burned, expired, revoked, locked, reported.
The signature
Every delivery carries `secretpaste-signature: t=<unix seconds>,v1=<hex>`, where the hex is HMAC-SHA-256 over `t.body` keyed with the signing secret you were shown once at creation. Compare in constant time, and refuse a timestamp older than five minutes so a captured delivery cannot be replayed at you later.
import { createHmac, timingSafeEqual } from "node:crypto";
// Verify before you parse. The body must be the raw bytes we sent, not a re-serialised object.
export function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((pair) => pair.split("=")));
const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
return age < 300 && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}In Python
The same three steps: split the header, recompute over `t.body`, compare without leaking timing.
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(pair.split("=", 1) for pair in header.split(","))
signed = f"{parts['t']}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
fresh = abs(time.time() - int(parts["t"])) < 300
return fresh and hmac.compare_digest(expected, parts["v1"])In Go
As above. Write the timestamp and the dot before the body rather than concatenating, so a large payload is not copied twice.
func Verify(rawBody []byte, header, secret string) bool {
parts := map[string]string{}
for _, pair := range strings.Split(header, ",") {
if key, value, ok := strings.Cut(pair, "="); ok {
parts[key] = value
}
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(parts["t"] + "."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
stamp, err := strconv.ParseInt(parts["t"], 10, 64)
if err != nil || time.Since(time.Unix(stamp, 0)) > 5*time.Minute {
return false
}
return hmac.Equal([]byte(expected), []byte(parts["v1"]))
}Prove your endpoint before you need it
A test ping is a real, signed delivery about nothing: no secret is involved, so it is safe to fire at an endpoint you are still setting up. It appears in the delivery log beside the real ones.
curl -X POST https://api.secretpaste.com/v1/webhooks/<id>/test \
-H 'authorization: Bearer sp_live_...'
# 202 {"delivery_id":"...","queued":true}When we stop
Failures are retried with backoff. After 20 consecutive failures the endpoint is switched off and its owner is emailed - an endpoint broken for that long is not going to be fixed by a hundred more attempts. A single success resets the count.