Webhooks
To receive Atlas Labs webhooks, subscribe a public HTTPS URL with POST /webhooks, send a test ping, and verify each delivery's webhook-signature against the raw request body. The tested snippets below do that in curl, JavaScript, Python and openssl, including the one-day window after a secret rotation when two signatures arrive.
Subscribe a URL to one or more event types and Atlas Labs pushes each state change in your sandbox to you, instead of you polling for it. Every state change writes one event row inside the same transaction as the change itself, so the event feed, the long-poll endpoint and webhooks can never disagree with each other. A webhook is sent only for the events you subscribed to, and only after the subscription is active.
ATLAS_OFFICE_KEY from your environment. After you start a sandbox on the hub, the Connect-it panel shows the same commands with your keys already in place. How do I receive and verify an Atlas Labs webhook in five minutes?
- Get a public
https://URL on port443: a Zapier, Make, n8n Cloud or Pipedream URL, or your own server behind a tunnel or reverse proxy. - Subscribe it with
POST /webhooks(the snippet below) and keep thewhsec_secret from the response: it is shown once. - Send the test ping with
POST /webhooks/{id}/test. The subscription turnsactiveas soon as your URL answers2xx. - Verify every delivery with the function under Verify the signature, reply
2xxwithin the five-second timeout, and ignore any eventidyou have already handled.
snippets/06-subscribe-webhook.sh proven against the live API (create, replay, test ping) #!/usr/bin/env bash
# Subscribe a public HTTPS URL (port 443) to events, keep the secret, send the test ping.
# Up to 3 subscriptions per sandbox. Needs WEBHOOK_URL, for example a Zapier Catch Hook URL.
set -euo pipefail
BASE="${ATLAS_BASE:-https://automationatlas.io/labs/api/v1}"
: "${ATLAS_OFFICE_KEY:?run: source atlas-keys.env}"
: "${WEBHOOK_URL:?set WEBHOOK_URL to your public https:// endpoint}"
created=$(curl -sS -X POST "$BASE/webhooks" \
-H "Authorization: Bearer $ATLAS_OFFICE_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: subscribe-orders-1" \
-d "$(jq -n --arg url "$WEBHOOK_URL" '{url: $url, events: ["office.sales_order.received"], description: "new orders"}')")
echo "$created" | jq '.data | {id, status, url_host, events}'
# The whsec_ secret is returned once. Replaying the same Idempotency-Key never repeats it (secret is null).
secret=$(jq -r '.data.secret // empty' <<<"$created")
[ -n "$secret" ] && (umask 077; printf '%s' "$secret" > atlas-webhook-secret.txt; chmod 600 atlas-webhook-secret.txt) || echo "no secret in this response (a replay?)" >&2
ID=$(jq -r '.data.id' <<<"$created")
# The subscription starts as pending_verification and activates when this ping gets a 2xx.
curl -sS -X POST "$BASE/webhooks/$ID/test" -H "Authorization: Bearer $ATLAS_OFFICE_KEY" | jq .data
curl -sS "$BASE/webhooks/$ID" -H "Authorization: Bearer $ATLAS_OFFICE_KEY" | jq '.data | {status, consecutive_failures, last_error_class}'
snippets/06-subscribe-webhook.mjs proven against the live API (create, replay, test ping) // Subscribe a public HTTPS URL (port 443) to events, keep the secret, send the test ping (Node 20+).
// Up to 3 subscriptions per sandbox. Run: WEBHOOK_URL=https://... node 06-subscribe-webhook.mjs
import { chmodSync, writeFileSync } from "node:fs";
const BASE = process.env.ATLAS_BASE ?? "https://automationatlas.io/labs/api/v1";
const headers = { Authorization: `Bearer ${process.env.ATLAS_OFFICE_KEY}`, "Content-Type": "application/json" };
const res = await fetch(`${BASE}/webhooks`, {
method: "POST",
headers: { ...headers, "Idempotency-Key": "subscribe-orders-1" },
body: JSON.stringify({
url: process.env.WEBHOOK_URL,
events: ["office.sales_order.received"], // or ["*"] for every event type
description: "new orders",
}),
});
const { data, error } = await res.json();
if (!res.ok) throw new Error(`${res.status} ${error.code} ${JSON.stringify(error.details ?? {})}`);
// The whsec_ secret is returned once. Replaying the same Idempotency-Key never repeats it (secret is null).
if (data.secret) {
writeFileSync("atlas-webhook-secret.txt", data.secret, { mode: 0o600 });
chmodSync("atlas-webhook-secret.txt", 0o600); // `mode` only applies to a new file
} else console.warn("no secret in this response (a replay?)");
console.log(data.id, data.status, data.url_host);
// The subscription starts as pending_verification and activates when this ping gets a 2xx.
const ping = await fetch(`${BASE}/webhooks/${data.id}/test`, { method: "POST", headers });
console.log("test ping", ping.status);
snippets/06-subscribe-webhook.py proven against the live API (create, replay, test ping) """Subscribe a public HTTPS URL (port 443) to events, keep the secret, send the test ping.
Up to 3 subscriptions per sandbox. pip install requests
Run: WEBHOOK_URL=https://... python 06-subscribe-webhook.py
"""
import os
import requests
BASE = os.environ.get("ATLAS_BASE", "https://automationatlas.io/labs/api/v1")
HEADERS = {"Authorization": f"Bearer {os.environ['ATLAS_OFFICE_KEY']}"}
res = requests.post(
f"{BASE}/webhooks",
headers={**HEADERS, "Idempotency-Key": "subscribe-orders-1"},
json={
"url": os.environ["WEBHOOK_URL"],
"events": ["office.sales_order.received"], # or ["*"] for every event type
"description": "new orders",
},
timeout=30,
)
body = res.json()
if not res.ok:
err = body["error"]
raise SystemExit(f"{res.status_code} {err['code']} {err.get('details')}")
hook = body["data"]
# The whsec_ secret is returned once. Replaying the same Idempotency-Key never repeats it (secret is null).
if hook.get("secret"):
fd = os.open("atlas-webhook-secret.txt", os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
f.write(hook["secret"])
os.chmod("atlas-webhook-secret.txt", 0o600) # the mode above only applies to a new file
else:
print("no secret in this response (a replay?)")
print(hook["id"], hook["status"], hook["url_host"])
# The subscription starts as pending_verification and activates when this ping gets a 2xx.
ping = requests.post(f"{BASE}/webhooks/{hook['id']}/test", headers=HEADERS, timeout=30)
print("test ping", ping.status_code)
Subscribe
The same call with the plain curl form. events takes one or more event types from the catalogue, or "*" for every type; the sandbox.webhook.* bookkeeping events are never delivered, even for "*".
POST /labs/api/v1/webhooks curl -s -X POST https://automationatlas.io/labs/api/v1/webhooks \
-H "Authorization: Bearer lab_off_yourkey" \
-H "Content-Type: application/json" \
-d '{"url": "https://hooks.zapier.com/hooks/catch/123456/abcdef/", "events": ["office.sales_order.received"]}' The response includes a signing secret (whsec_…), shown once; the URL itself is never returned, only its host. Send a test ping before relying on the subscription:
POST /labs/api/v1/webhooks/{id}/test curl -s -X POST https://automationatlas.io/labs/api/v1/webhooks/whk_01J.../test \
-H "Authorization: Bearer lab_off_yourkey" A new subscription only starts delivering once its test ping has returned a 2xx response.
Which events can I subscribe to?
Subscribe to any of these types, or to "*" for all of them. An unknown name is refused with 422 invalid_event_type.
| System | Event types |
|---|---|
office.* | office.sales_order.received, office.sales_order.accepted, office.sales_order.declined, office.sales_order.expired, office.requisition.received, office.requisition.approved, office.requisition.rejected, office.purchase_order.created, office.purchase_order.approval_required, office.purchase_order.approved, office.supplier_invoice.received, office.supplier_invoice.paid, office.customer_invoice.created, office.customer_invoice.paid, office.customer_invoice.overdue, office.payment.received, office.payment.overdue, office.goods_receipt.recorded, office.contract_lot.offered, office.contract_lot.accepted, office.contract_lot.declined, office.contract_lot.expired, office.contract_lot.delivered, office.market_trade.executed |
factory.* | factory.work_order.created, factory.work_order.ready, factory.shipment.created, factory.print_run.started, factory.print_run.completed, factory.requisition.created, factory.stock.below_reorder_point, factory.delivery.arrived, factory.goods_receipt.recorded, factory.market_delivery.received |
hr.* | hr.employee.sick, hr.employee.returned, hr.employee.quit, hr.employee.dismissed, hr.employee.hired, hr.leave_request.created, hr.payroll.due, hr.payroll.late, hr.payroll.paid |
world.* | world.major_order.updated, world.lore_event.started |
sandbox.* | sandbox.mission.completed, sandbox.achievement.unlocked, sandbox.expiring, sandbox.bankrupt, sandbox.disruption.ended |
Payload
{
"id": "evt_01J...",
"type": "factory.print_run.completed",
"created_at": "2026-09-17T10:00:00Z",
"sim_date": "1897-03-04",
"sandbox_id": "sbx_01J...",
"system": "factory",
"simulation": true,
"data": { "...": "..." },
"links": { "self": "/labs/api/v1/factory/print-runs/pr_01J..." }
} A body larger than 4,096 bytes keeps only short scalar fields in data, plus "truncated": true; follow links.self for the full record.
| Header | Value |
|---|---|
webhook-id | The event id (evt_…), the same on every retry. Deduplicate on it. |
webhook-timestamp | Integer seconds since the epoch at send time. It changes on every attempt, so never use it to order events. |
webhook-signature | v1,<base64 HMAC-SHA256>. For one day after rotate-secret it holds two space-separated values, v1,<new> v1,<old>: accept either. |
content-type | application/json |
user-agent | AtlasLabs-Webhooks/1.0 (+https://automationatlas.io/labs/docs/webhooks/) |
Verify the signature
Deliveries are signed using the Standard Webhooks convention: headers webhook-id, webhook-timestamp and webhook-signature. The signature is v1,<base64 HMAC-SHA256> of {id}.{timestamp}.{raw body}, keyed by the base64-decoded part of your whsec_ secret. Always verify against the raw request body, before any JSON parsing.
- Check the timestamp. Refuse a delivery whose
webhook-timestampis more than five minutes from your clock (300seconds in the snippets), so a captured request cannot be replayed later. - Accept either signature. For one day after you rotate the secret, the header carries two values. The snippets accept a match against any of them.
- Compare in constant time (
timingSafeEqual,hmac.compare_digest) and deduplicate on the eventid.
Node.js, Python and openssl
snippets/07-verify-webhook.mjs verified against the repo's own signer: good, rotated, tampered, wrong-secret, stale // Receive and verify an Atlas Labs webhook (Node 20+, no dependencies).
// Standard Webhooks: headers webhook-id, webhook-timestamp, webhook-signature.
// signature = "v1," + base64(HMAC-SHA256(key, `${id}.${timestamp}.${rawBody}`)), where key is
// the base64-decoded part of your whsec_ secret. During the 24 h after rotate-secret the header
// holds two space-separated values ("v1,<new> v1,<old>"); accept either.
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
const SECRET = process.env.ATLAS_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_S = 300;
export function verify(secret, headers, rawBody, nowS = Math.floor(Date.now() / 1000)) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const header = headers["webhook-signature"];
if (!id || !timestamp || !header) return false;
if (Math.abs(nowS - Number(timestamp)) > TOLERANCE_S) return false; // stale or replayed
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`, "utf8").digest();
return header.split(" ").some((part) => {
const [version, value] = part.split(",");
if (version !== "v1" || !value) return false;
const given = Buffer.from(value, "base64");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}
if (import.meta.url === `file://${process.argv[1]}`) {
const seen = new Set(); // deliveries are at-least-once: de-duplicate on the event id
createServer((req, res) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const rawBody = Buffer.concat(chunks).toString("utf8"); // verify the RAW body, before JSON.parse
if (!verify(SECRET, req.headers, rawBody)) {
res.writeHead(401).end();
return;
}
const event = JSON.parse(rawBody);
if (!seen.has(event.id)) {
seen.add(event.id);
console.log(event.type, event.id, event.links?.self);
}
res.writeHead(200).end(); // reply 2xx quickly: the timeout is 5 s and 5 consecutive failures disable the subscription
});
}).listen(Number(process.env.PORT ?? 3000));
}
snippets/07-verify-webhook.py verified against the repo's own signer: good, rotated, tampered, wrong-secret, stale """Receive and verify an Atlas Labs webhook (Python 3.9+, standard library only).
Standard Webhooks: headers webhook-id, webhook-timestamp, webhook-signature.
signature = "v1," + base64(HMAC-SHA256(key, f"{id}.{timestamp}.{raw_body}")), where key is
the base64-decoded part of your whsec_ secret. During the 24 h after rotate-secret the header
holds two space-separated values ("v1,<new> v1,<old>"); accept either.
"""
import base64
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ.get("ATLAS_WEBHOOK_SECRET", "") # whsec_...
TOLERANCE_S = 300
seen = set() # deliveries are at-least-once: de-duplicate on the event id
def verify(secret, headers, raw_body: bytes, now=None) -> bool:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
header = headers.get("webhook-signature")
if not (msg_id and timestamp and header):
return False
if abs((now or time.time()) - int(timestamp)) > TOLERANCE_S: # stale or replayed
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for part in header.split(" "):
version, _, value = part.partition(",")
if version == "v1" and hmac.compare_digest(value, expected):
return True
return False
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
raw = self.rfile.read(int(self.headers.get("content-length", 0))) # verify the RAW body
if not verify(SECRET, self.headers, raw):
self.send_response(401)
self.end_headers()
return
event = json.loads(raw)
if event["id"] not in seen:
seen.add(event["id"])
print(event["type"], event["id"], event.get("links", {}).get("self"))
self.send_response(200) # reply 2xx quickly: the timeout is 5 s and 5 consecutive failures disable the subscription
self.end_headers()
if __name__ == "__main__":
HTTPServer(("0.0.0.0", int(os.environ.get("PORT", 3000))), Handler).serve_forever()
snippets/07-verify-webhook.sh run by the repo's tests against a vector signed by the server's own signer #!/usr/bin/env bash
# Check one saved delivery by hand with openssl (handy when debugging a Zapier/Make/n8n receiver).
# Usage: ./07-verify-webhook.sh whsec_... "$WEBHOOK_ID" "$WEBHOOK_TIMESTAMP" 'raw-body-exactly-as-received' 'v1,base64sig'
set -euo pipefail
secret=$1 id=$2 timestamp=$3 body=$4 header=$5
hexkey=$(printf '%s' "${secret#whsec_}" | base64 -d | xxd -p -c 256)
expected=$(printf '%s.%s.%s' "$id" "$timestamp" "$body" \
| openssl dgst -sha256 -mac HMAC -macopt "hexkey:$hexkey" -binary | base64)
for part in $header; do
[ "${part#v1,}" = "$expected" ] && { echo "signature OK"; exit 0; }
done
echo "signature MISMATCH" >&2; exit 1
ATLAS_WEBHOOK_SECRET environment variable. In a no-code tool, replace process.env.ATLAS_WEBHOOK_SECRET with a secret from an input field or the tool's credential store, never a value pasted into the step's code. Retries and limits
- Retries happen at
10 s,60 sand300 safter a non-2xxresponse or a timeout, then the delivery is dropped. - A subscription auto-disables after
5consecutive failures. CheckGET /webhooks/{id}/deliveries(status code, latency and error class, never response bodies) if events stop arriving, then re-enable it withPATCH /webhooks/{id}. - Delivery is at-least-once and order is not guaranteed once a delivery has been retried or deferred. See Ordering below.
- Up to
3subscriptions per sandbox, at most60deliveries per minute per sandbox, a5second timeout, and at most1KB of your response is read and then discarded. - A URL that is not public
https://on port443is refused with422 webhook_url_rejected;details.reasonnames the rule (scheme_not_https,port_not_allowed,hostname_not_public, and so on). A local server onlocalhostor a self-hosted tool on its default port must sit behind a tunnel or reverse proxy first.
Ordering
Events are created in order inside your sandbox, but they are delivered independently. If one delivery fails and waits for a retry, or is deferred because the sandbox has used its per-minute delivery budget, a later event can reach you first. Expect events out of order and the same event more than once. The webhook payload carries no sequence number, so cope with these fields it does carry:
- Deduplicate on
id. The eventidis the same on every attempt, and it is also thewebhook-idheader. Keep the ids you have handled and ignore a repeat. - Order by
created_at, not arrival time. It is when the event was recorded, so it does not change with retries. Do not usewebhook-timestampfor this: it is the time of each send attempt and changes on every retry.sim_dateis the in-world day and several events share one, so it is not an ordering key either. - Treat an event as a nudge to read the current state.
links.selfpoints at the resource that changed. Fetching it gives you the latest state whatever order the events arrived in, so a late event cannot roll your copy back. - Need a strict order? Read the event feed,
GET /events?after_seq=<n>. It returns the same events (sameid) with a per-sandboxseq, ascending, and is the authoritative order. A webhook receiver can use it to fill any gap, then carry on from the highestseqseen.
Abuse policy and opt-out
Only public HTTPS destinations on port 443 are accepted: no private, loopback or link-local addresses, no userinfo in the URL, and redirects are never followed. If your endpoint is receiving unwanted Atlas Labs traffic (for example, someone subscribed a URL you don't control), use the contact form with Bug report or Security as the topic, and give the destination host; it will be added to a delivery block list.