Webhooks
Every mutating action — index, upload, rename, delete, lock, a role change, … — writes an audit event (see Logs). Once that event is durably committed, Atlas fires a best-effort HTTP call to every configured target whose filters match it:
flowchart LR
action["Mutating request\n(e.g. declare a document)"] --> commit["Event committed"]
commit -->|"after the response,\nnever before commit"| dispatch["Match targets by\nevents / bucket filter"]
dispatch --> t1["Target: Airflow"]
dispatch --> t2["Target: Prefect"]
The call fires only after the whole request — database commit included — has actually finished, so a webhook never fires for an event that later gets rolled back by something else in the same request. There’s no queue and no retry: a target that’s down or slow just fails and gets logged. A persisted delivery log with retries is the natural upgrade path once guaranteed delivery becomes a real requirement — not needed for a first version with no existing job/queue infrastructure to lean on.
Target configuration
Section titled “Target configuration”Targets live in a YAML file, not a database table — the same reasoning as
.env: this can hold real bearer tokens and HMAC secrets, so it belongs in
a file kept out of git and off any multi-admin-editable UI, not a row a
superuser edits at runtime.
cp api/webhooks.example.yaml webhooks.yaml# edit webhooks.yaml, then:ATLAS_WEBHOOKS_CONFIG_PATH=./webhooks.yamlEach entry under targets::
| Field | Required | Default | Description |
|---|---|---|---|
name |
yes | — | For log lines only — identifies which target a delivery (or failure) belongs to. |
url |
yes | — | Delivery URL. Supports ${variable} substitution, see below. |
method |
no | POST |
HTTP method used for delivery. |
headers |
no | {} |
A map of header name to value. Values support ${variable} substitution — this is where a static Authorization: Bearer ... header goes. |
body |
no | {} |
Arbitrary JSON, sent as the request body. Every string value, at any nesting depth, supports ${variable} substitution. |
events |
no | every action | A list of action names (INDEX, UPLOAD, DELETE, RENAME, …) to fire on. Omit to match every action. |
bucket |
no | every bucket | Restrict delivery to one bucket. Omit to match every bucket. |
secret |
no | unset | If set, every delivery is signed — see below. |
skip_tls_verification |
no | false |
Disables certificate verification for this target only. |
targets: - name: airflow-ocr-pipeline url: https://airflow.example.com/api/v1/dags/ocr_pipeline/dagRuns headers: Authorization: Bearer REPLACE_WITH_A_REAL_TOKEN events: [INDEX, UPLOAD] bucket: invoices secret: a-shared-secret-only-this-target-and-atlas-know body: conf: bucket: ${bucket} path: ${path} object_id: ${object_id}url, every header value, and every string inside body (however deeply
nested) go through the same substitution:
| Variable | Value |
|---|---|
${event_id} |
The audit event’s own id. |
${uuid} |
A fresh id generated per delivery — distinct from ${event_id} because the same event can go out to several targets. |
${action} |
INDEX, UPLOAD, DELETE, RENAME, … |
${bucket} |
The bucket the action happened in. |
${path} |
The object’s key. Same value as ${key}. |
${key} |
Alias for ${path}. |
${actor} |
Who did it. Empty for the unattended upload-relay path (no user to attribute an auto-declared document to). |
${object_id} |
The related Document’s id. Empty if the action targeted a key that was never declared. |
${name} |
The related Document’s file name. Empty under the same condition as ${object_id}. |
${state} |
The related Document’s current state. Empty under the same condition as ${object_id}. |
${context_json} |
The event’s action-specific detail as a JSON string, e.g. {"new_key": "..."} on a rename. |
An unknown placeholder in body/url/headers is left as-is rather than
raising — a target that doesn’t reference ${object_id} shouldn’t care
that it’s empty for an undeclared key.
What actually goes out on the wire
Section titled “What actually goes out on the wire”POST /api/v1/dags/ocr_pipeline/dagRuns HTTP/1.1Host: airflow.example.comContent-Type: application/jsonAuthorization: Bearer REPLACE_WITH_A_REAL_TOKENX-Atlas-Signature: sha256=5f1c2e...a91b
{"conf": {"bucket": "invoices", "path": "2024/invoice_042.pdf", "object_id": "0198e2b4-..."}}When secret is set, X-Atlas-Signature carries an HMAC-SHA256 over the
exact bytes of the request body, so a receiver can confirm a delivery
actually came from this Atlas instance and wasn’t tampered with in transit
— without needing to manage a client certificate the way mutual TLS would
require. Verifying it on the receiving end is a few lines:
import hashlibimport hmac
def verify(secret: str, signature_header: str, raw_body: bytes) -> bool: algo, _, signature = signature_header.partition("=") expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return algo == "sha256" and hmac.compare_digest(expected, signature)