Skip to content

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:

HeaderValue
webhook-idThe event id. The same value on every retry and for every receiver.
webhook-timestampUnix seconds, per attempt.
webhook-signaturev1,<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 created
event = wh.verify(request.body, request.headers) # raises on a bad signature or a stale timestamp
import { Webhook } from "standardwebhooks";
const wh = new Webhook("whsec_…");
const event = wh.verify(rawBody, headers); // rawBody: the exact bytes received

The 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-After on a 429 or 503 is honoured when it is later than the next scheduled delay, capped at 6h. We never come back before the time you ask for.
  • 410 Gone stops 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.

TypeWhen it firesdata highlightsminimal keeps
decision.renderedThe 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, modelEverything but the rationale, the action’s context and the signal values
operator_verdict.recordedA person, or the system when time ran out, answered on an action.verdict + label, operator, auth_method, assurance_status, note, fail_closed_reasonAll but the display name and the note
gate.releasedThe gate could not get a judgment and the deployment let the write through.kind, reason, release_label, gate_instruction, posture, declared_controlAll but declared_control
session.killedSomeone pulled the kill switch on a whole run.killed_at, killed_by, auth_methodAll but the display name
hold.expiredA paused action ran out of time with nobody waiting on it.decision_id, expired_at, governing_postureAll
rule.committedAn admin wrote, changed, switched or deleted a rule.changes, before and after snapshots, committed_by, bodyAll but the body and the display name
settings.changedAn admin changed gate mode, posture, the hold window.config_version, tier, changes, actorAll but the display name
webhook.subscription_changedSomeone added, changed or removed another receiver.subscription_id, label, url_host, change, event_types, detail_level, actorAll but the display name
webhook.testAn admin pressed Test receiver.messageAll
execution.approved execution.claimed execution.executed execution.cancelled execution.confirm_not_executedWhat happened to an approved write afterwards.ordinal, event, from_state, to_state, provenance, reason, operatorAll but the display name
execution.claim_refused execution.cancel_refused execution.ack_refused execution.resume_refusedAn attempt the gate refused.Same as above, with reason naming the refusalAll 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.

ValueMeaningVerdicts behind it
runThe 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
waitA person has to answer before the write happens.hold
refuseThe 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, HTTPException
from 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 Gone when you retire the endpoint, so the deliveries stop immediately instead of retrying for three days.

What an admin needs from you

  • An https URL. 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.