ATLAS Webhooks are powered by Hook0, an open-source webhook infrastructure service. To ensure that incoming webhook events are genuine and originate from ATLAS, your application must verify the signature included with every webhook delivery. ATLAS signs each webhook using HMAC-SHA256 and sends the signature in the X-Hook0-Signature HTTP header.
Webhook requests must not be processed until this signature is verified.
For every webhook event:
Hook0 generates:
a timestamp (t)
a list of headers used in the signature (h)
the raw JSON body
Hook0 constructs a signed_payload:
signed_payload =
timestamp + "." +
headers_list + "." +
header_values_joined + "." +
raw_body
Hook0 computes:
HMAC_SHA256(signed_payload, SIGNING_SECRET)
The resulting signature is included in the header:
X-Hook0-Signature: t=...,h=...,v1=...
Only the v1 signature needs to be validated.
When your server receives a webhook:
Read the entire X-Hook0-Signature header and extract:
t (timestamp)
h (space-separated list of headers)
v1 (the signature)
Example:
t=1733399090,h=X-Event-Id X-Event-Type,v1=abcdef123456...
From the value of h, split the header names.
Read each of these headers from the incoming HTTP request in the same order.
Join these values with "." to produce header_values_joined.
Concatenate:
signed_payload = t + "." + h + "." + header_values_joined + "." + raw_body
Important: Use the exact raw body as received (no formatting changes, whitespace changes, or re-serialization).
Using the signing secret from your Hook0 subscription:
expected_signature = HMAC_SHA256(signed_payload, SIGNING_SECRET)
Hex-encode the result.
Compare:
expected_signature
v1 from X-Hook0-Signature
Use a constant-time comparison to prevent timing attacks.
If they do not match → reject the request.
Convert t to a UNIX timestamp and ensure:
now - t < 5 minutes
If the timestamp is too old → reject as a possible replay attack.
Before processing any webhook event, your server must:
Parse X-Hook0-Signature
Rebuild signed_payload
Compute HMAC-SHA256 using your signing secret
Compare with v1 (constant time)
Validate timestamp freshness
A webhook should be trusted only if all the steps above succeed.
using System.Security.Cryptography;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("webhooks/hook0")]
public class Hook0Webhook : ControllerBase
{
private readonly string secret = "YOUR_SIGNING_SECRET";
[HttpPost]
public async Task<IActionResult> Receive()
{
var sigHeader = Request.Headers["X-Hook0-Signature"].ToString();
if (string.IsNullOrEmpty(sigHeader)) return Unauthorized();
string timestamp = null, headersList = null, sigV1 = null;
foreach (var part in sigHeader.Split(','))
{
var kv = part.Split('=');
if (kv.Length != 2) continue;
if (kv[0] == "t") timestamp = kv[1];
if (kv[0] == "h") headersList = kv[1];
if (kv[0] == "v1") sigV1 = kv[1];
}
Request.EnableBuffering();
string body;
using (var reader = new StreamReader(Request.Body, leaveOpen:true))
body = await reader.ReadToEndAsync();
Request.Body.Position = 0;
var headerNames = headersList.Split(' ');
var headerValues = headerNames.Select(h => Request.Headers[h].ToString());
var headerJoin = string.Join('.', headerValues);
var signedPayload = $"{timestamp}.{headersList}.{headerJoin}.{body}";
var expected = new HMACSHA256(System.Text.Encoding.UTF8.GetBytes(secret))
.ComputeHash(System.Text.Encoding.UTF8.GetBytes(signedPayload));
var expectedHex = Convert.ToHexString(expected).ToLower();
if (!CryptographicOperations.FixedTimeEquals(
System.Text.Encoding.UTF8.GetBytes(expectedHex),
System.Text.Encoding.UTF8.GetBytes(sigV1)))
return Unauthorized();
return Ok();
}
}
const crypto = require("crypto");
function verifyHook0(req) {
const sigHeader = req.headers["x-hook0-signature"];
if (!sigHeader) return false;
const parts = Object.fromEntries(
sigHeader.split(",").map(p => p.split("="))
);
const timestamp = parts.t;
const headersList = parts.h;
const signature = parts.v1;
const headerNames = headersList.split(" ");
const headerValues = headerNames.map(h => req.headers[h.toLowerCase()]);
const headerJoined = headerValues.join(".");
const signedPayload =
`${timestamp}.${headersList}.${headerJoined}.${req.rawBody}`;
const expected = crypto
.createHmac("sha256", process.env.HOOK0_SECRET)
.update(signedPayload)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}
import hmac
import hashlib
def verify_hook0(request, secret):
sig_header = request.headers.get("X-Hook0-Signature")
if not sig_header:
return False
parts = dict(p.split("=") for p in sig_header.split(","))
timestamp = parts["t"]
headers_list = parts["h"]
signature = parts["v1"]
header_names = headers_list.split(" ")
header_values = [request.headers.get(h) for h in header_names]
header_joined = ".".join(header_values)
signed_payload = (
f"{timestamp}.{headers_list}.{header_joined}."
f"{request.data.decode()}"
)
expected = hmac.new(
secret.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)