Skip to content

Verifying webhook signatures

Anyone can POST to your webhook URL. Before you trust a delivery, verify its signature to prove it came from Atlast and wasn’t tampered with in transit.

When Atlast sends a webhook, it computes:

signature = HMAC-SHA256(raw_request_body, your_signing_secret)

and sends the result as a lowercase hex string in the X-Atlast-Signature header. You recompute the same HMAC on your side and check that the two match.

Your signing secret is generated in the Atlast Portal (Settings → Integrations → Webhook → Signing secret). Keep it secret — it’s the shared key the signature is based on.

Express
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SIGNING_SECRET = process.env.ATLAST_WEBHOOK_SECRET;
app.post(
'/webhooks/atlast',
// Capture the RAW body as a Buffer — required for an exact-bytes HMAC.
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.get('X-Atlast-Signature') ?? '';
const expected = crypto
.createHmac('sha256', SIGNING_SECRET)
.update(req.body) // req.body is a Buffer (the raw bytes)
.digest('hex');
// Constant-time comparison avoids timing attacks.
const valid =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!valid) return res.status(401).send('invalid signature');
const event = JSON.parse(req.body.toString('utf8'));
// ...handle event (de-dupe on event.id, then re-fetch)...
res.sendStatus(200);
},
);

Use this known-good fixture to confirm your code produces the right signature. With:

  • Secret: whsec_8f3c2a1b9d4e5f60a7b8c9d0e1f2a3b4

  • Raw body (exact bytes):

    {"id":"evt_9f8e7d6c5b4a3210","event":"job.published","occurredAt":"2026-06-09T12:34:56.000Z","data":{"jobId":"job-550e8400-e29b-41d4-a716-446655440000","publicId":"abc123def456"}}

your HMAC-SHA256 hex signature must equal:

32d341edfb49797164fe1c15529715501060037ac44c177c8e4111738762a947

If your code produces exactly this value, you’re verifying correctly. If not, the usual cause is hashing a re-serialized body instead of the raw bytes.

  • Read the raw body before parsing JSON.
  • HMAC-SHA256, hex digest, lowercase.
  • Compare with a constant-time function (timingSafeEqual, hmac.compare_digest, hash_equals).
  • Reject with 401 on mismatch; never process an unverified payload.
  • Only after verifying: de-duplicate on the event id, then re-fetch the job.