Webhooks
Novarch POSTs one signed JSON event to your endpoint every time the gate decides something: a verdict, an operator’s answer, a kill switch, a rule change, a settings change, or what happened to an approved write afterwards.
An admin adds your endpoint in ADMIN → WEBHOOKS and gives you a signing secret. Nothing you answer changes what the gate does. This is a record export: a 500 from you is retried, and no agent waits on it.
The envelope
Every event looks the same. Only data differs by type.
{ "id": "evt_01JB8Z3N2QK7F5W9XA6TCDR4MH", "type": "decision.rendered", "schema_version": 1, "occurred_at": "2026-09-14T21:59:05+00:00", "subscription_id": "whsub_7f3a91c4", "data": { "session": { "session_id": "sess_a41f…", "tenant_id": "acme", "team_id": "ap", "agent_id": "ap-agent-01" }, "action": { "action_id": "act_9c2e…", "action_type": "process_invoice", "action_label": "Pay invoice" }, "decision_id": "dec_5b0d…", "verdict": "hold", "verdict_label": "PAUSED", "gate_instruction": "wait", "rule": { "id": "AP-2", "version": 3, "mode": "enforce", "name": "Unverified Bank Change" }, "signals_cited": [ { "name": "bank_change_days_ago", "value": 6 }, { "name": "bank_change_verification_evidenced", "value": false } ], "rationale": "The vendor's bank details changed six days ago and …", "hold_mode": "wait", "hold_deadline": "2026-09-14T22:04:05+00:00", "model": "claude-haiku-4-5", "prompt_template_version": "v3" }}Every timestamp in an event is RFC 3339 with a numeric UTC offset
(2026-09-14T21:59:05+00:00), to the second. A parser that expects a trailing
Z will not read them.
Each POST carries the event id, its timestamp and the signature in headers:
| Header | Value |
|---|---|
webhook-id | The event id. The same value on every retry and for every receiver. |
webhook-timestamp | Unix seconds, per attempt. |
webhook-signature | v1,<base64 HMAC-SHA256>. During a secret rotation it carries two signatures, space-separated. |
Verifying the signature
Novarch signs with Standard Webhooks, so the library does this for you. Verify the raw bytes you received; a JSON round-trip changes them and the signature will not match.
from standardwebhooks import Webhook
wh = Webhook("whsec_…") # the secret shown once when the receiver was createdevent = wh.verify(request.body, request.headers) # raises on a bad signature or a stale timestampimport { Webhook } from "standardwebhooks";
const wh = new Webhook("whsec_…");const event = wh.verify(rawBody, headers); // rawBody: the exact bytes receivedThe libraries reject a timestamp outside a five-minute window by default, which is what stops a copied request being replayed at you next week. Libraries exist for Python, JS/TS, Go, Ruby, PHP, Java, Rust, C# and Elixir.
Delivery, dedup and ordering
At-least-once. A delivery is attempted until it succeeds, until the receiver
answers 410 Gone, until an admin disables the receiver, or until the window
closes. Any 2xx counts as success.
Dedup on webhook-id. You will occasionally see an event twice: a response
that was slow enough to time out on our side still arrived on yours. The id is
stable across every retry and is the same for every receiver, so keep the ones
you have seen and drop the repeats.
No ordering. Events are delivered as workers pick them up, so
operator_verdict.recorded can arrive before the decision.rendered it answers.
occurred_at is second-precision, so several events can share a stamp. execution.* events carry an ordinal that orders them within one
action.
The retry schedule, measured from each failed attempt and jittered ±20%:
5s, 1m, 5m, 30m, 1h, 2h, 4h, then every 6h, for up to 72 hours from the
first attempt. About 18 tries. After that the delivery is marked failed and an
admin can send everything that failed again with one button.
Retry-Afteron a 429 or 503 is honoured when it is later than the next scheduled delay, capped at6h. We never come back before the time you ask for.410 Gonestops that delivery immediately. The receiver stays enabled; answer 410 for an endpoint you have retired.- Every other 4xx and 5xx, and every timeout, is retried on the schedule above.
Each attempt has ten seconds for DNS, connect, TLS, upload and your response. We read 256 bytes of your body and close, so a short error message reaches the admin who is looking at the delivery log. Redirects are not followed: give the admin the final URL.
Event reference
data always carries a session block on session-scoped events
(session_id, tenant_id, team_id, agent_id), and an action block
(action_id, action_type, action_label) on action-scoped ones.
| Type | When it fires | data highlights | minimal keeps |
|---|---|---|---|
decision.rendered | The judge ruled on an action the agent wanted to take. | verdict + verdict_label, gate_instruction, observed_action, rule, signals_cited with values, rationale, fail_closed_reason, hold_mode, hold_deadline, model | Everything but the rationale, the action’s context and the signal values |
operator_verdict.recorded | A person, or the system when time ran out, answered on an action. | verdict + label, operator, auth_method, assurance_status, note, fail_closed_reason | All but the display name and the note |
gate.released | The gate could not get a judgment and the deployment let the write through. | kind, reason, release_label, gate_instruction, posture, declared_control | All but declared_control |
session.killed | Someone pulled the kill switch on a whole run. | killed_at, killed_by, auth_method | All but the display name |
hold.expired | A paused action ran out of time with nobody waiting on it. | decision_id, expired_at, governing_posture | All |
rule.committed | An admin wrote, changed, switched or deleted a rule. | changes, before and after snapshots, committed_by, body | All but the body and the display name |
settings.changed | An admin changed gate mode, posture, the hold window. | config_version, tier, changes, actor | All but the display name |
webhook.subscription_changed | Someone added, changed or removed another receiver. | subscription_id, label, url_host, change, event_types, detail_level, actor | All but the display name |
webhook.test | An admin pressed Test receiver. | message | All |
execution.approved execution.claimed execution.executed execution.cancelled execution.confirm_not_executed | What happened to an approved write afterwards. | ordinal, event, from_state, to_state, provenance, reason, operator | All but the display name |
execution.claim_refused execution.cancel_refused execution.ack_refused execution.resume_refused | An attempt the gate refused. | Same as above, with reason naming the refusal | All but the display name |
A receiver subscribes to every type by default, or to a list an admin picks.
Two more types exist in our vocabulary and never reach a receiver:
notification.digest and notification.storm_summary. They ride the same
outbox, but a mail server built into the platform’s own notifications feature
produces and consumes them for one email list at a time — a receiver cannot
subscribe to either, and neither ever appears in a delivery log entry made
for you.
A kill fans out: one session.killed, then one operator_verdict.recorded per
hold it denied and one execution.cancelled per approval it cancelled. You never
have to infer what a kill touched.
webhook.subscription_changed goes to every receiver except the one that
changed, and carries the host alone.
gate_instruction
decision.rendered and gate.released carry gate_instruction: what the gate
told the agent to do. Key your alerting on this: the verdict word alone gets
two cases wrong.
| Value | Meaning | Verdicts behind it |
|---|---|---|
run | The write went ahead. | pass; warn, which is a rule under observation that recorded what it would have done; unjudged, a release with no judgment behind it |
wait | A person has to answer before the write happens. | hold |
refuse | The write did not happen. | kill, and the three answers a resumed write can get back |
A rule in observe mode produces verdict: "warn" with the judge’s real word in
observed_action, and gate_instruction: "run". A rule that would have blocked
something did not block it.
Detail levels
An admin picks one per receiver.
detailed sends the full record: ids, verdict, rule, cited signals with
their values, the judge’s rationale, the action’s context, operator display
names.
minimal sends ids and the words a rule keys on: verdict, rule, signal
names, gate_instruction. No rationale, no context, no names. Use it for a SIEM
that matches on fields.
Writing a receiver
from fastapi import FastAPI, Request, HTTPExceptionfrom standardwebhooks import Webhook
app = FastAPI()wh = Webhook("whsec_…")seen: set[str] = set() # in production: a table with a TTL
@app.post("/novarch")async def receive(request: Request): body = await request.body() try: event = wh.verify(body, dict(request.headers)) except Exception: raise HTTPException(status_code=400, detail="bad signature")
if event["id"] in seen: # at-least-once: dedup on webhook-id return {"ok": True} seen.add(event["id"])
if event["type"] == "decision.rendered" and event["data"]["gate_instruction"] == "wait": page_the_on_call(event["data"])
return {"ok": True} # any 2xx; answer before doing slow work- Answer fast. Ten seconds is the whole budget including TLS. Queue the work and return.
- Verify before you parse. Use the raw bytes.
- Dedup on
webhook-id, and treat replays as normal traffic. - Answer
410 Gonewhen you retire the endpoint, so the deliveries stop immediately instead of retrying for three days.
What an admin needs from you
- An
httpsURL. A private or internal address works only where whoever runs the Novarch deployment has allowed it by environment variable, and the portal cannot open one. - No credentials in the URL.
https://user:pass@…is sent without them and your endpoint will see no basic auth. Authenticate on the signature, or put a token in the path. - Somewhere to keep the secret. It is shown once when the receiver is created. Rotating is easy; recovering it is impossible.