Browse documentation

Webhooks

Verifying webhook signatures

Every webhook delivery carries a single Carebit-Signature header. There is no separate Carebit-Timestamp header - the timestamp is inside Carebit-Signature.

The header is a comma-separated list of key=value pairs:

Carebit-Signature: t=1755428400,v1=5a1c...abcd,v1=9f2e...beef
  • t= is the unix timestamp of the signature.
  • v1= is an HMAC-SHA256 hex digest.

There may be more than one v1= value. During a 24-hour rotation window after you rotate an endpoint's signing secret, Carebit signs every request with the new secret AND the previous secret so a receiver that has both configured can accept either.

Steps

  1. Parse the header. Extract t and every v1= value into a list.
  2. Compute the signed payload string. It is the timestamp, a literal period, then the exact raw request body bytes. Do not re-serialize the JSON; use the raw bytes as received.
  3. Compute HMAC-SHA256(secret, signed_payload) using each of your endpoint's active signing secrets. Compare each expected digest against every v1= value in the header using a constant-time comparison. If any v1= matches any active secret, the signature is valid.
  4. Reject the request if t is more than 5 minutes older than your server clock, or more than 5 minutes in the future.

Capture the raw body before any JSON parser touches it. A single whitespace difference will invalidate the signature.

Code example

import crypto from "node:crypto";

// activeSecrets is [currentSecret, previousSecret?] read from your config.
// Returns false for any malformed input; never throws.
export function verifyCarebitSignature({
  header,
  rawBody,
  activeSecrets,
  toleranceSeconds = 300,
}) {
  if (typeof header !== "string" || header.length === 0) 
    return false;
  }
  const pairs = header.split(",").map((pair) => pair.trim().split("=", 2));
  const timestampPair = pairs.find(([key]) => key === "t");
  const timestampRaw = timestampPair?.[1];
  if (typeof timestampRaw !== "string" || !/^\d{1,15}$/.test(timestampRaw)) 
    return false;
  }
  const timestamp = Number(timestampRaw);
  if (!Number.isFinite(timestamp)) 
    return false;
  }
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) 
    return false;
  }
  const signatures = pairs
    .filter(
      ([key, value]) =>
        key === "v1" &&
        typeof value === "string" &&
        /^[0-9a-f]{64}$/i.test(value),
    )
    .map(([, value]) => value.toLowerCase());
  if (signatures.length === 0) 
    return false;
  }
  const bodyBuffer = Buffer.isBuffer(rawBody)
    ? rawBody
    : Buffer.from(String(rawBody), "utf8");
  const signedPayload = Buffer.concat(
    Buffer.from(`${timestamp}.`, "utf8"),
    bodyBuffer,
  ]);
  return activeSecrets.some((secret) => 
    if (typeof secret !== "string" || secret.length === 0) 
      return false;
    }
    const expected = crypto
      .createHmac("sha256", secret)
      .update(signedPayload)
      .digest("hex");
    const expectedBytes = Buffer.from(expected, "hex");
    return signatures.some((provided) => 
      const providedBytes = Buffer.from(provided, "hex");
      if (providedBytes.length !== expectedBytes.length) 
        return false;
      }
      return crypto.timingSafeEqual(providedBytes, expectedBytes);
    });
  });
}

Deterministic test vectors

The published OpenAPI document under x-carebit-signature-vectors contains three deterministic vectors: primary, rotation, and stale_timestamp_rejected. Every reference implementation above produces identical digests against those vectors.

Prefer plain text? Append ?format=md or send Accept: text/markdown to receive this page as raw Markdown.