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
- Parse the header. Extract
tand everyv1=value into a list. - 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.
- Compute
HMAC-SHA256(secret, signed_payload)using each of your endpoint's active signing secrets. Compare each expected digest against everyv1=value in the header using a constant-time comparison. If anyv1=matches any active secret, the signature is valid. - Reject the request if
tis 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.