Signing and verify

Every Outemit delivery is signed with Standard Webhooks-compatible HMAC. Verify before you trust the payload.

Headers and signed content

Each delivery includes:

  • webhook-id – stable message id for dedupe
  • webhook-timestamp – unix seconds when signed
  • webhook-signature – space-separated v1,<base64> parts

Signed content is the UTF-8 string ${webhook-id}.${webhook-timestamp}.${rawBody}. The HMAC key is the base64-decoded bytes after stripping the whsec_ prefix. Digest is base64-encoded SHA-256 HMAC.

Timestamp tolerance

Reject deliveries whose webhook-timestamp is older (or newer) than about 5 minutes relative to your server clock. This limits replay of captured requests. Skewed clocks are a common false negative: NTP-sync your receivers.

Dual-secret rotation

When you rotate an endpoint secret, Outemit dual-signs with current and previous secrets until previous_valid_until. Your verifier should accept any valid v1, part. Deploy the new secret to receivers before the grace window ends, or call rotate again if you need more time.

API: POST /api/v1/endpoints/:id/rotate-secret (see API reference).

Full HMAC snippets

Use these when you cannot depend on the Standard Webhooks library, or want to see the algorithm.

ts
import crypto from "node:crypto";

function verify(secret, rawBody, headers) {
  const id = headers["webhook-id"];
  const ts = headers["webhook-timestamp"];
  const sigHeader = headers["webhook-signature"]; // "v1,<b64> ..."
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const toSign = `${id}.${ts}.${rawBody}`;
  const expected = crypto.createHmac("sha256", key).update(toSign).digest("base64");
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(ts)) > 300) return false; // 5m tolerance
  return sigHeader.split(" ").some((part) => {
    const [, b64] = part.split(",");
    try {
      return b64 && crypto.timingSafeEqual(Buffer.from(b64), Buffer.from(expected));
    } catch {
      return false;
    }
  });
}

verify("whsec_...", rawBody, headers);

standardwebhooks library

Prefer Outemit SDK verify helpers (SDKs) or the official Standard Webhooks packages when your stack has them:

ts
import { Webhook } from "standardwebhooks";

const wh = new Webhook("whsec_...");
const payload = wh.verify(rawBody, {
  "webhook-id": headers["webhook-id"],
  "webhook-timestamp": headers["webhook-timestamp"],
  "webhook-signature": headers["webhook-signature"],
});

Common mistakes

  • Verifying a re-serialized JSON body instead of the raw bytes
  • Forgetting to strip whsec_ before base64-decoding the key
  • Using hex HMAC output instead of base64
  • Comparing signatures with === instead of a constant-time compare
  • Ignoring timestamp tolerance (replay risk) or setting it to zero (clock skew failures)
  • Rotating secrets without dual-verify on the receiver during the grace window
  • Putting whsec_ values in client-side code or public repos
signed content
${webhook-id}.${webhook-timestamp}.${rawBody}