Skip to content

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.

Start a sandbox and your keys are filled in for you. The snippets on this page read 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.
Start a sandbox

How do I receive and verify an Atlas Labs webhook in five minutes?

  1. Get a public https:// URL on port 443: a Zapier, Make, n8n Cloud or Pipedream URL, or your own server behind a tunnel or reverse proxy.
  2. Subscribe it with POST /webhooks (the snippet below) and keep the whsec_ secret from the response: it is shown once.
  3. Send the test ping with POST /webhooks/{id}/test. The subscription turns active as soon as your URL answers 2xx.
  4. Verify every delivery with the function under Verify the signature, reply 2xx within the five-second timeout, and ignore any event id you have already handled.
curl 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}'
JavaScript 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);
Python 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 "*".

curl Subscribe a webhook — 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:

curl Send a test ping — 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.

Reference table — the webhook event types Atlas Labs can send, generated from the API's own catalogue, not part of the fictional simulation.
SystemEvent 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

JSON A delivery's JSON body
{
  "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.

Reference table — the headers on every webhook delivery, not part of the fictional simulation.
HeaderValue
webhook-idThe event id (evt_…), the same on every retry. Deduplicate on it.
webhook-timestampInteger seconds since the epoch at send time. It changes on every attempt, so never use it to order events.
webhook-signaturev1,<base64 HMAC-SHA256>. For one day after rotate-secret it holds two space-separated values, v1,<new> v1,<old>: accept either.
content-typeapplication/json
user-agentAtlasLabs-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-timestamp is more than five minutes from your clock (300 seconds 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 event id.

Node.js, Python and openssl

JavaScript 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));
}
Python 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()
Shell 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
The secret is not in the code. The snippets read it from the 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 s and 300 s after a non-2xx response or a timeout, then the delivery is dropped.
  • A subscription auto-disables after 5 consecutive failures. Check GET /webhooks/{id}/deliveries (status code, latency and error class, never response bodies) if events stop arriving, then re-enable it with PATCH /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 3 subscriptions per sandbox, at most 60 deliveries per minute per sandbox, a 5 second timeout, and at most 1 KB of your response is read and then discarded.
  • A URL that is not public https:// on port 443 is refused with 422 webhook_url_rejected; details.reason names the rule (scheme_not_https, port_not_allowed, hostname_not_public, and so on). A local server on localhost or 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 event id is the same on every attempt, and it is also the webhook-id header. 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 use webhook-timestamp for this: it is the time of each send attempt and changes on every retry. sim_date is 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.self points 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 (same id) with a per-sandbox seq, ascending, and is the authoritative order. A webhook receiver can use it to fill any gap, then carry on from the highest seq seen.

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.

Back to the quickstart · Integrations