Skip to main content
Every webhook request is signed with your subscription’s signing secret (returned once when the subscription is created or its secret is regenerated). Verify the signature on every request before trusting the payload — this proves the request came from Riverside and was not tampered with.

Request headers

HeaderExampleDescription
x-riverside-signaturev1=8b2e...f0Versioned HMAC signature of the request.
x-riverside-timestamp1752595283Unix timestamp (seconds) when the request was signed.
content-typeapplication/jsonThe body is always JSON.

How the signature is computed

signed_string = "{x-riverside-timestamp}:{raw_request_body}"
signature     = "v1=" + HMAC_SHA256(signing_secret, signed_string)   // hex
The signature is prefixed with v1= to allow future scheme changes. Compute your own HMAC over the raw request body (the exact bytes received — do not re-serialize the parsed JSON) and compare it to the value in x-riverside-signature.

Verification steps

  1. Read the raw request body before any JSON parsing.
  2. Read x-riverside-timestamp and x-riverside-signature.
  3. Reject the request if the timestamp is too old (e.g. more than 5 minutes) to guard against replay attacks.
  4. Compute v1=HMAC_SHA256(secret, "{timestamp}:{rawBody}").
  5. Compare against the received signature using a constant-time comparison.
  6. Only then parse the body and process the event.

Example (Node.js / Express)

import express from "express";
import crypto from "node:crypto";

const SIGNING_SECRET = process.env.RIVERSIDE_WEBHOOK_SECRET;
const MAX_SKEW_SECONDS = 5 * 60;

if (!SIGNING_SECRET) {
  throw new Error("RIVERSIDE_WEBHOOK_SECRET is required");
}

const app = express();

// Capture the raw body — the signature is computed over the exact bytes.
app.use("/hooks/riverside", express.raw({ type: "*/*" }));

app.post("/hooks/riverside", (req, res) => {
  const signature = req.header("x-riverside-signature") ?? "";
  const timestamp = Number(req.header("x-riverside-timestamp"));
  const rawBody = req.body.toString("utf8");

  // 1. Reject stale requests (replay protection).
  if (!Number.isFinite(timestamp) ||
      Math.abs(Date.now() / 1000 - timestamp) > MAX_SKEW_SECONDS) {
    return res.status(400).send("stale timestamp");
  }

  // 2. Recompute and compare in constant time.
  const expected =
    "v1=" +
    crypto
      .createHmac("sha256", SIGNING_SECRET)
      .update(`${timestamp}:${rawBody}`)
      .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(401).send("bad signature");
  }

  // 3. Verified — parse and handle. Dedupe on event.id.
  const event = JSON.parse(rawBody);
  // ... enqueue / process event ...

  res.status(200).send("ok");
});
Never verify against the re-serialized JSON — whitespace and key ordering differ from the bytes we signed and the check will fail. Always sign/verify the raw body.
Acknowledge fast: respond 2xx as soon as you’ve verified and durably enqueued the event, then process asynchronously. Slow endpoints hit the delivery timeout and get retried.