Verify signatures
Every webhook delivery is HMAC-SHA256 signed with your endpoint's secret. Verify the signature, enforce a timestamp window, and de-duplicate on the event id before you trust a payload.
Your webhook endpoint is a public HTTPS URL: anyone can POST to it. Before you act on a payload, prove it came from Keepable and has not been tampered with or replayed. Every delivery is HMAC-SHA256 signed with the secret returned when you registered the endpoint.
The signature header
Each delivery carries a signature header:
X-Keepable-Signature: t=1735380000,v1=abc123def456...tis the Unix timestamp when the signature was generated.v1is the hex-encoded HMAC-SHA256 of${t}.${rawBody}, keyed by your endpoint secret.
The signed message is the timestamp, a literal dot, and the raw request body. You must hash the body exactly as received, before any JSON parse or re-serialisation, which would change the bytes and break the signature.
Capture the raw body bytes. Frameworks that auto-parse JSON often discard the original bytes; re-serialising the parsed object will not reproduce them. Configure your route to keep the raw body for the signed path.
Verify
Three checks make a signature trustworthy: recompute the HMAC, compare it in constant time, and reject stale timestamps to defeat replay.
import crypto from "node:crypto";
export function verify(rawBody: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=") as [string, string]),
);
const t = parts.t;
const v1 = parts.v1;
if (!t || !v1) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
// Constant-time compare to defuse timing attacks.
const a = Buffer.from(v1);
const b = Buffer.from(expected);
const signatureOk = a.length === b.length && crypto.timingSafeEqual(a, b);
// Reject signatures older than 5 minutes (replay window).
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
return signatureOk && fresh;
}import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
func Verify(body []byte, header, secret string) bool {
parts := map[string]string{}
for _, kv := range strings.Split(header, ",") {
if k, v, ok := strings.Cut(kv, "="); ok {
parts[k] = v
}
}
t, err := strconv.ParseInt(parts["t"], 10, 64)
if err != nil {
return false
}
// Reject signatures older than 5 minutes (replay window).
if time.Since(time.Unix(t, 0)) > 5*time.Minute {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(parts["t"] + "." + string(body)))
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(parts["v1"]), []byte(expected))
}Always use a constant-time comparison (crypto.timingSafeEqual,
hmac.Equal): a normal == leaks, byte by byte, how much of a forged signature
was correct, which is enough to forge one over many attempts.
Replay protection
A valid, recent signature still does not mean new. Keepable retries failed deliveries, so the same event can legitimately arrive more than once, so your processing must be idempotent.
Two layers handle this:
Timestamp window. Reject any signature whose t is more than ~5 minutes old
(the check above). This bounds how long a captured-and-replayed request stays
valid.
Event-id de-duplication. Use the CloudEvents envelope id (also surfaced as
X-Keepable-Event-Id) as a processing key. Record it after the first successful
handling and skip any id you have already seen. This gives you exactly-once
effects on top of at-least-once delivery.
app.post("/hooks/keepable", async (req, res) => {
const raw = req.rawBody; // raw bytes, not the parsed object
const sig = req.header("X-Keepable-Signature") ?? "";
if (!verify(raw, sig, process.env.KEEPABLE_WEBHOOK_SECRET!)) {
return res.status(400).send("bad signature");
}
const event = JSON.parse(raw); // CloudEvents envelope
if (await seen(event.id)) return res.status(200).send("duplicate");
await enqueue(event); // hand off; do the real work async
await remember(event.id);
res.status(200).send("ok"); // ack fast
});Testing locally
Until a "send test webhook" button lands in the sender portal, point an endpoint
at a tunnelling tool (such as a local tunnel or webhook.site), trigger a real
action: send a letter to yourself, then verify
the signature against your stored secret. Then inspect the attempt in the
delivery log to confirm Keepable saw your
200.
Webhooks
React to events as they happen instead of polling. Register an endpoint, subscribe to event types, and receive signed CloudEvents when content arrives, agreements progress, or a recipient registers.
Recipient surface
The recipient-facing surface that powers the Keepable inbox apps. Covers the account profile, email capture and verification, the assurance-level model, and delivery notifications including held-pending releases.