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.
How signing works
Section titled “How signing works”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.
Recipes
Section titled “Recipes”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); },);import hashlibimport hmacimport osfrom flask import Flask, request, abort
app = Flask(__name__)SIGNING_SECRET = os.environ["ATLAST_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/atlast")def atlast_webhook(): raw = request.get_data() # raw bytes — NOT request.json signature = request.headers.get("X-Atlast-Signature", "") expected = hmac.new(SIGNING_SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected): # constant-time abort(401)
event = request.get_json() # ...handle event (de-dupe on event["id"], then re-fetch)... return "", 200<?php$secret = getenv('ATLAST_WEBHOOK_SECRET');$raw = file_get_contents('php://input'); // raw body$signature = $_SERVER['HTTP_X_ATLAST_SIGNATURE'] ?? '';$expected = hash_hmac('sha256', $raw, $secret);
if (!hash_equals($expected, $signature)) { // constant-time http_response_code(401); exit('invalid signature');}
$event = json_decode($raw, true);// ...handle event (de-dupe on $event['id'], then re-fetch)...http_response_code(200);Verify your implementation
Section titled “Verify your implementation”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:
32d341edfb49797164fe1c15529715501060037ac44c177c8e4111738762a947If 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.
Checklist
Section titled “Checklist”- 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
401on mismatch; never process an unverified payload. - Only after verifying: de-duplicate on the event
id, then re-fetch the job.