Webhooks
Subscribe an endpoint
One call creates the endpoint and returns its signing secret. Here are the URL rules, who is allowed to subscribe, and what the refusals mean.
One call creates an endpoint and hands back its signing secret. Keep the secret: it is the only thing that lets your receiver prove a delivery came from us.
Who can subscribe
A workspace admin, or an API key carrying the admin scope. A webhook streams a
workspace's activity to a URL of someone's choosing, which is an admin decision rather than a
per-member one. Listing is open to any member, and so is removing an endpoint.
What a URL has to be
- HTTPS. A delivery carries signed information about your documents, so plaintext HTTP is refused at subscribe time.
- A public host. Private, link-local and cloud metadata addresses are refused, and the check runs again at delivery, because a DNS record can change after an endpoint was saved.
- A host that resolves. We look the name up while you are subscribing, so a typo in a hostname is a 422 on that call rather than an endpoint that silently never fires.
- Its own destination. Redirects are not followed, so a 3xx is reported as your receiver's status rather than chased.
Subscribe an endpoint
curl -X POST https://api.engramdynamics.org/v1/webhooks \
-H "Authorization: Bearer <your key>" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/engram",
"events": ["corpus.ready", "sync_run.completed"]
}'
{
"id": "w_6c4a...",
"url": "https://hooks.example.com/engram",
"secret": "whsec_Qv7...",
"events": ["corpus.ready", "sync_run.completed"],
"active": true,
"last_delivery_at": null,
"last_status": null,
"created_at": "2026-09-13T10:12:20Z"
}
An empty events list subscribes to everything. Event names are validated against the
closed set on Overview and events, and an unknown one is a 422 that lists
the valid names.
hook = requests.post(
f"{BASE}/webhooks",
headers=HEADERS,
json={"url": "https://hooks.example.com/engram", "events": []},
timeout=30,
).json()
save_secret(hook["secret"]) # the HMAC key your receiver will need
List and remove endpoints
curl https://api.engramdynamics.org/v1/webhooks \
-H "Authorization: Bearer <your key>"
curl -X DELETE https://api.engramdynamics.org/v1/webhooks/w_6c4a... \
-H "Authorization: Bearer <your key>"
The listing shows every endpoint the workspace has, with last_status and
last_delivery_at, which is the fastest way to see that a receiver has started returning
500s. secret is included only for a workspace admin, or an API key with the
admin scope; every other caller sees null. Anything holding the key can forge a delivery
your receiver would accept, so the listing must not be the one place that hands it to everyone.
Delete is a real delete, not a flag, and it stops delivery immediately. The audit log keeps the record that the endpoint existed and when it went.
Replace a signing secret
Rotating gives an endpoint a new signing secret and keeps everything else about it, so the id your dashboards and scripts already hold does not change. Tenant admin, like subscribing.
curl -X POST https://api.engramdynamics.org/v1/webhooks/w_6c4a.../rotate -H "Authorization: Bearer <your admin key>"
{
"id": "w_6c4a...",
"url": "https://hooks.example.com/engram",
"secret": "whsec_NEW...",
"events": ["corpus.ready", "sync_run.completed"],
"active": true,
"last_delivery_at": "2026-09-13T10:11:43Z",
"last_status": "200",
"created_at": "2026-09-13T10:12:20Z"
}
The old secret stops verifying on that call. There is no overlap window, because the reason to rotate is that the old key is not trusted any more and a grace period would keep it alive for exactly as long as the grace period. Deliveries in between will fail your signature check, and if your receiver answers those with a 401 or a 403 we do not retry them — a 4xx tells us the request itself is wrong, so those events are gone. Two ways to rotate without losing any: put the new secret in your receiver first and rotate second, or have the receiver answer a signature it cannot verify with a 500 for the few minutes it takes you to deploy, which keeps the events on the retry ladder until it can.
Rotating is not plan-gated. A workspace on any tier can replace a leaked key, because an upgrade prompt in the middle of an incident is an ugly thing to do to somebody.
Pause and resume
Pausing stops the calls without losing the endpoint, which is what you want for a receiver going into maintenance or one that has started failing while you work out why.
curl -X PATCH https://api.engramdynamics.org/v1/webhooks/w_6c4a... -H "Authorization: Bearer <your admin key>" -H "Content-Type: application/json" -d '{"active": false}'
A paused endpoint gets nothing: no new deliveries, and no retries that were already scheduled
when you paused it. Nothing is saved up while it is off, so resuming with
{"active": true} starts from the next event rather than replaying the gap. Coming back
to a backlog of stale "your run finished" calls would be worse than coming back quiet.
To catch up on what you missed, read the state rather than the events:
GET /v1/corpora/{id}/sync-runs is the full ingestion history and
GET /v1/corpora/{id} carries the readiness counts.
Both routes are also on the app: Settings → Webhooks has a pause toggle and a rotate button on every endpoint.
Limits and refusals
| You will see | When |
|---|---|
403 plan_feature | The workspace plan does not include webhooks. See Plans and allowances. |
| 403, tenant admin required | The caller is a member, or a key without the
admin scope. |
| 409 | The workspace already has 10 endpoints, which is the ceiling
(MAX_WEBHOOKS_PER_TENANT). Remove one you no longer use. |
| 422 | An unknown event name, a non-HTTPS URL, a host that is not publicly routable, or a host that does not resolve at all. |
Test before you rely on it
The cheapest end-to-end test is a real one. Subscribe your endpoint to
sync_run.completed, upsert one small document into a throwaway document base, and watch
it arrive:
curl -X POST https://api.engramdynamics.org/v1/corpora/c_7a1f.../documents/upsert \
-H "Authorization: Bearer <your key>" \
-H "Content-Type: application/json" \
-d '{"path": "webhook-test.md", "text": "hello", "onboard": false}'
Then check last_status on the endpoint. A 200 there means your receiver
answered; anything else is the status it returned, or error for a connection failure.
Next
Verify a signature before your receiver acts on anything.