Keepable docs
Webhooks

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...
  • t is the Unix timestamp when the signature was generated.
  • v1 is 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.

Putting it together
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.