Webhooks
Security and signing
Atlas signs every delivery with an HMAC-SHA256 over a timestamp and the raw body. Verify that signature, refuse replays, and rotate your secret without missing a delivery.
Verify every signature
Every delivery carries an Atlas-Signature header in this form:
Atlas-Signature: t=1790000000,v1=<64 hexadecimal characters>tis the moment Atlas signed this attempt, in seconds since the Unix epoch. The same value is sent on its own in theAtlas-Timestampheader.v1is the lowercase hexadecimal HMAC-SHA256 of the text<t>.<raw body>, keyed with your signing secret. While a rotation is in progress the header carries onev1for each secret, so accept the delivery when any one of them matches.- The raw body means the exact bytes Atlas sent. Never verify against JSON you parsed and serialized again: a change in whitespace or key order changes the digest, and every verification will fail.
To verify a delivery, compute that HMAC with your secret and compare it with each v1 using a constant-time comparison. A plain === or == comparison reveals timing information that helps an attacker forge a signature, so use the constant-time function your language provides. Refuse the delivery when nothing matches, and refuse it when t is more than five minutes from your own clock. Every attempt is signed at the moment it is sent, so a genuine retry always carries a fresh timestamp.
import crypto from "node:crypto";
import express from "express";
const SIGNING_SECRET = process.env.ATLAS_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
const app = express();
// Read the raw body: the signature covers the exact bytes Atlas sent.
// Never verify against JSON you parsed and serialized again.
app.post("/atlas/webhook", express.raw({ type: "*/*" }), (req, res) => {
const header = req.header("atlas-signature") ?? "";
const body = req.body.toString("utf8");
let timestamp = null;
const signatures = [];
for (const part of header.split(",")) {
const [key, value] = part.split("=", 2);
if (key === "t" && /^\d+$/.test(value ?? "")) timestamp = Number(value);
if (key === "v1") signatures.push(value);
}
if (timestamp === null || signatures.length === 0) {
return res.status(400).send("missing signature");
}
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) {
return res.status(400).send("timestamp outside tolerance");
}
const expected = crypto
.createHmac("sha256", SIGNING_SECRET)
.update(`${timestamp}.${body}`)
.digest();
const valid = signatures.some((signature) => {
const given = Buffer.from(signature, "hex");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
if (!valid) return res.status(401).send("invalid signature");
// Valid. Skip an Atlas-Event-Id you have already processed, queue the
// work, and answer at once.
res.status(200).send("ok");
});To check your own implementation, enter a signing secret, a timestamp, and a sample body below. The tool computes the exact Atlas-Signature header Atlas would send. It runs entirely in your browser, so the secret never leaves this page.
Enter a secret to compute the signature.
signed message: 1783412096.{"id":"evt_9f2c","type":"task.completed","apiVersion":"2026-10-01","createdAt":"2026-07-07T12:34:56.000Z","tenantId":"ten_123","actor":{"id":"usr_789","kind":"USER"},"data":{"object":{"id":"t_456","status":"DONE"}}}
Replay protection
A valid signature proves that a delivery came from Atlas. On its own, it does not stop someone who captured a genuine delivery from sending it to you again. Your receiver needs two further checks.
The first is the timestamp tolerance. Refuse a delivery whose t is more than five minutes from your clock, in either direction. The timestamp is part of the signed text, so it cannot be changed to look recent without breaking the signature. A captured request is therefore useless after five minutes.
The second closes those five minutes. Record the Atlas-Event-Id of every event you process, and skip an event you have already processed. Every attempt and every replay of one event carries the same event id, so this check also makes your receiver safe when Atlas retries a delivery your server processed but did not answer in time. Keep each id for at least 35 hours, the length of the full retry schedule, and longer if you replay deliveries by hand.
Use both checks
Atlas-Event-Id you have already processed. A valid signature alone does not protect you against a replay.Key rotation
Rotate a webhook signing secret with POST /v1/webhooks/{id}/rotate-key, using a token that holds the webhooks:manage scope, or from the webhook's page in Settings. The new secret is returned exactly once, so store it before you do anything else.
curl -X POST https://api.example.com/v1/webhooks/{id}/rotate-key \
-H "Authorization: Bearer atlas_pat_REPLACE_ME"
# The new secret is returned exactly once:
# { "id": "...", "signingKeyVersion": 2, "secret": "whsec_...",
# "previousSigningKeyExpiresAt": "..." }After a rotation, Atlas signs every attempt with both secrets for 58 hours and 45 minutes, and the header carries two v1 values. That period is longer than the full retry schedule, so a delivery that was first attempted before the rotation still carries a signature your receiver can check with the old secret. Move your receiver to the new secret at any point inside the period. The response field previousSigningKeyExpiresAt states when the old secret stops signing.
Because a receiver accepts a delivery when any v1 matches, the samples above need no change for a rotation: they keep working with whichever secret you have configured.
Secrets and network
Your signing secret is shown once
Endpoints must be public HTTPS addresses