# 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.

Canonical: `https://automationatlas.io/labs/docs/webhooks/` · Checked: `2026-10-15`

Atlas Labs is a fictional company's API; every figure in every response is simulation data.

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.

## 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](#verify), 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)*

```bash
#!/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)*

```javascript
// 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)*

```python
"""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`*

```bash
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`*

```bash
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.*

| 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

*JSON · A delivery's JSON body*

```json
{
  "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.*

| 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](https://www.standardwebhooks.com/) 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*

```javascript
// 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*

```python
"""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*

```bash
#!/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](#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](https://automationatlas.io/labs/contact/) 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](https://automationatlas.io/labs/docs/quickstart/) · [Integrations](https://automationatlas.io/labs/docs/tools/)
