Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.autousers.ai/llms.txt

Use this file to discover all available pages before exploring further.

Every webhook delivery carries a signature header. Verify it. Refusing to verify is the single most common way customers ship exploitable webhook receivers; we do not consider an unverified endpoint a working integration.

The header

Autousers-Signature: t=1714867200,v1=8e3a4d9c1f...
FieldMeaning
tUnix epoch seconds at signing time.
v1Hex-encoded HMAC-SHA256 of ${t}.${rawBody}.
The signing secret is the whsec_* plaintext value you received once when creating the endpoint. Rotate via POST /v1/webhooks/{id}/rotate-secret — the new plaintext is shown once; the old secret keeps verifying for 24 hours so deploys can roll.

The verification rules

A payload is valid iff all four hold:
  1. The signature header parses (one t, one v1).
  2. Math.abs(Date.now()/1000 - t) <= toleranceSec (default 300, i.e. 5 minutes).
  3. HMAC_SHA256(secret, "${t}.${rawBody}").hex() === v1.
  4. Comparison uses a constant-time equality check (crypto.timingSafeEqual or equivalent).
Use the raw bytes of the request body. Re-serialising via JSON.stringify(JSON.parse(body)) will corrupt the signature on any payload with non-canonical key order.

Reference implementations

import crypto from "node:crypto";

export function verifyAutousersSignature(
  rawBody: string | Buffer,
  header: string | undefined,
  secret: string,
  toleranceSec = 300
): boolean {
  if (!header) return false;
  const parts = Object.fromEntries(
    header.split(",").map((p) => {
      const [k, ...rest] = p.split("=");
      return [k.trim(), rest.join("=")];
    })
  );
  const t = Number(parts.t);
  const sig = parts.v1;
  if (!t || !sig) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;

  const payload =
    typeof rawBody === "string"
      ? Buffer.from(`${t}.${rawBody}`)
      : Buffer.concat([Buffer.from(`${t}.`), rawBody]);

  const expected = crypto
    .createHmac("sha256", secret)
    .update(payload)
    .digest("hex");

  // Both buffers must be the same length for timingSafeEqual.
  const a = Buffer.from(sig, "hex");
  const b = Buffer.from(expected, "hex");
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

// Express handler — note `express.raw()` to keep the bytes intact.
import express from "express";
const app = express();

app.post(
  "/webhooks/autousers",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const ok = verifyAutousersSignature(
      req.body, // Buffer because of express.raw()
      req.header("autousers-signature"),
      process.env.AUTOUSERS_WEBHOOK_SECRET!
    );
    if (!ok) return res.status(400).send("invalid signature");
    const event = JSON.parse(req.body.toString("utf8"));
    // ... handle event ...
    res.status(200).end();
  }
);

Common pitfalls

Don’t re-serialise the body. Frameworks that parse JSON before your handler runs will hand you a Ruby Hash / Python dict / JS object whose JSON.stringify representation does not match the bytes we signed. Use the raw-body middleware (express.raw, Request.body(), request.body.read).
Don’t compare with === / == / .equal?. A naive equality check leaks signature bytes through timing. Use timingSafeEqual, hmac.compare_digest, hmac.Equal, or Rack::Utils.secure_compare.
Don’t trust req.body.timestamp over the header t. Some CDNs rewrite or strip request bodies. The t in the header is what we signed; verify against that, not against the JSON body.

Secret rotation

Rotate when an employee with secret access leaves, after any suspected leak, and at least annually:
curl -X POST https://app.autousers.ai/api/v1/webhooks/$ENDPOINT_ID/rotate-secret \
  -H "Authorization: Bearer $AUTOUSERS_API_KEY"
{
  "id": "whe_clxq3...",
  "secret": "whsec_n3wRotat3dS3cretShownOnceK33pSafe",
  "rotatedAt": "2026-05-04T11:00:00.000Z",
  "previousSecretValidUntil": "2026-05-05T11:00:00.000Z"
}
The previous secret keeps verifying for 24 hours, so a rolling deploy that updates secrets across replicas does not drop deliveries. After 24 hours, only the new secret is valid.