Skip to content

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.

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.

Terminal window
cp api/webhooks.example.yaml webhooks.yaml
# edit webhooks.yaml, then:
ATLAS_WEBHOOKS_CONFIG_PATH=./webhooks.yaml

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

POST /api/v1/dags/ocr_pipeline/dagRuns HTTP/1.1
Host: airflow.example.com
Content-Type: application/json
Authorization: Bearer REPLACE_WITH_A_REAL_TOKEN
X-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 hashlib
import 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)