API reference
One hook before an agent's tool call, one signed statement after it. The narrative version — what to call when, and what happens if we are down — is in the integration guide.
Heron judges an agent's action against the vendor's published policy and signs the decision. The verdict is enforced: the vendor honours it before executing, and reports what it actually did under its own signature.
The tenant is derived from the API key and from nothing else — no request names its own organization, project or environment, and no body below has a field for one.
Four computations must be byte-exact and a generated client will not get them right on its own: the canonical form behind every hash (RFC 8785), args_hash, the chain link, and the bytes of the execution-evidence signature. Check your implementation against the conformance vectors at /spec/test-vectors.json before your first request.
This document is served at /openapi.json and catalogued at /.well-known/api-catalog (RFC 9727). The narrative version — what to call when, and what happens if Heron is down — is at /docs/vendors.
Machine-readable
This page is generated from the OpenAPI document below, which is itself generated from the schemas that validate the requests. Nothing here is transcribed by hand, so the site cannot claim a field the API does not have.
/openapi.json— OpenAPI 3.1.0. Generate a client in any language./spec/test-vectors.json— known answers for the four values a generated client gets wrong./.well-known/api-catalog— RFC 9727 linkset, for tools that discover APIs rather than being pointed at them.
Generate the client; do not trust it with the hashes. Four values crossing this API are exact bytes rather than JSON shapes: the RFC 8785 canonical form behind every hash, args_hash, the chain link, and the bytes of the execution-evidence signature. A client that hashes its own serialization produces a plausible value that never matches ours — and because we never see your arguments, a wrong args_hash is accepted and stored rather than rejected. Reproduce the test vectors before your first real request.
Conventions
- Auth.
Authorization: Bearer hrn_live_<env>_<secret>. The key resolves to an environment, and through it to the project that owns the policy, the vendor keys and the evidence page. - Idempotency.
Idempotency-Key: <uuid>is required on/v1/actionsand/v1/executions. A replay returns the original result and never forks the chain. - Hashes travel as
sha256:<64 hex>, computed by the vendor over the unredacted data. Heron never sees the data itself. - Canonical JSON. Everything signed or hashed is canonicalized per RFC 8785 first. Key order must not decide whether a third party can verify us.
POST /v1/sessions
Open a session
Idempotent on external_id: a replay returns the existing session rather than forking the chain.
principal.ref must be opaque. An email or a phone number is rejected with 400 — storing a person, rather than a reference to one, is what we promise not to do.
original_request.hash is over the full text, which never travels. Beside it go anchors — the tokens for the recipients named in the request, the same ones the arguments carry, so the two can be compared without us holding a word of the conversation. An anchor that is an address rather than a token is rejected with 400, for the same reason principal.ref is: it is a person, and tokenising it here ourselves would key it differently from the ones in your arguments and quietly break the comparison it exists for.
task and grant are optional, and they are the two facts nothing else on this API can carry: which job this run belongs to, and what the run was allowed to do. Both are committed into the session's genesis record as digests, so what you state at open cannot be restated after the run misbehaves — and both are inert: no rule reads either, and no verdict changes because you sent one. task.ref and grant.ref are opaque references in your own namespace and are rejected with 400 if they look like an address, exactly as principal.ref is. grant.hash commits to your own delegation document, which never travels; grant.scope is the optional machine-readable half, written in the same classes the decision's classification uses. Inside scope, an absent key means unbounded on that dimension and an empty list is rejected — [] cannot say whether you meant "nothing is allowed" or "this is not bounded", and a delegation is the last document to guess in.
| Field | Type | Constraint | Notes |
|---|---|---|---|
external_idrequired | string | ≤ 200 chars | |
agentrequired | object | — | |
agent.external_idrequired | string | ≤ 200 chars | |
agent.name | string | ≤ 200 chars | |
agent.version | string | ≤ 50 chars | |
principalrequired | object | — | |
principal.typerequired | string | human · service | |
principal.refrequired | string | ≤ 200 chars | |
original_requestrequired | object | — | |
original_request.hashrequired | string | ^sha256:[0-9a-f]{64}$ | |
original_request.anchors | array of string | — | |
task | object | — | |
task.refrequired | string | ≤ 200 chars | |
task.parent_session_external_id | string | ≤ 200 chars | |
grant | object | — | |
grant.hashrequired | string | ^sha256:[0-9a-f]{64}$ | |
grant.ref | string | ≤ 200 chars | |
grant.scope | object | — | |
grant.scope.allowed_tools | array of string | — | |
grant.scope.bounds | object | — | |
grant.scope.bounds.operation | array of string | — | |
grant.scope.bounds.data_class | array of string | — | |
grant.scope.bounds.destination | array of string | — | |
grant.scope.bounds.magnitude | array of string | — | |
grant.scope.bounds.reversibility | array of string | — | |
grant.scope.expires_at | string | ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ · date-time | |
started_atrequired | string | ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ · date-time |
{
"external_id": "run_8812",
"agent": {
"external_id": "agent_billing_v2",
"name": "Billing agent",
"version": "2.1.0"
},
"principal": {
"type": "human",
"ref": "user_881"
},
"original_request": {
"hash": "sha256:ce144c1a6c7bf2e07cfad2edb8de6fcd9654240314c4e8f2114fff1da7d9062e",
"anchors": [
"<email:0f367fdcc159df07>"
]
},
"task": {
"ref": "job_4471",
"parent_session_external_id": "run_8810"
},
"grant": {
"hash": "sha256:ce144c1a6c7bf2e07cfad2edb8de6fcd9654240314c4e8f2114fff1da7d9062e",
"ref": "delegation_2261",
"scope": {
"allowed_tools": [
"gmail.send",
"crm.get_contact"
],
"bounds": {
"operation": [
"read",
"send"
],
"destination": [
"internal"
],
"magnitude": [
"single"
]
},
"expires_at": "2026-07-13T18:00:00.000Z"
}
},
"started_at": "2026-07-13T12:35:52.000Z"
}Responses
| Status | Meaning |
|---|---|
200 | The session was already open; nothing was changed. |
201 | Session opened. |
400 | Validation failed, or a required header is missing. |
401 | Missing or invalid API key. On the ingest path this means the actions being taken right now are going unrecorded — an incident, not a retryable error. |
409 | A session with this |
{
"session_id": "ses_2f9c1a",
"head_hash": "sha256:84c0ce3c697b3585d11022c55ae8642c4d98f68e44363f8e20a75ca3b297c54e"
}{
"error": {
"code": "bad_request",
"message": "Validation failed",
"detail": [
{
"path": "principal.ref",
"message": "principal.ref must not be an email address"
}
]
}
}{
"error": {
"code": "unauthorized",
"message": "Missing or invalid API key",
"detail": null
}
}{
"error": {
"code": "conflict",
"message": "Session already exists",
"detail": null
}
}| Field | Type | Constraint | Notes |
|---|---|---|---|
session_idrequired | string | — | |
head_hashrequired | string | ^sha256:[0-9a-f]{64}$ | The chain head. Extend it with the next action. |
POST /v1/actions
The before-action hook
Called before the tool runs, and the answer decides whether it runs at all. Budgeted for p95 < 50 ms; set a short client-side timeout and treat a slow Heron exactly like a DENY.
args_hash is over the unredacted arguments and is computed on your side — we never see the arguments themselves. args_redacted is redacted again on ours: we do not trust the vendor to have done it.
chain.prev_hash is optional, but echoing the previous receipt's record_hash is what lets Heron tell you dropped an event instead of silently accepting a hole.
| Name | In | Notes |
|---|---|---|
Idempotency-Keyrequired | header | A replay returns the original result and never forks the chain. Required — a retry without it would be a second action. |
| Field | Type | Constraint | Notes |
|---|---|---|---|
session_external_idrequired | string | ≤ 200 chars | |
seqrequired | integer | — | |
toolrequired | object | — | |
tool.namerequired | string | ≤ 200 chars | |
tool.provider | string | ≤ 100 chars | |
tool.server | string | ≤ 500 chars | |
args_redacted | object | — | |
args_hashrequired | string | ^sha256:[0-9a-f]{64}$ | |
signals | object | ≤ 32 keys | Scalar facts the vendor computes on its edge, where the conversation still exists, and sends already reduced. Heron never receives the text itself. Keys outside this vocabulary are stored but read by nothing.
A human's involvement is one statement, and it comes in two shapes. Answering a STEP_UP Heron issued: A commitment is not a signal, and
A model's claim is one statement too, in four parts, and it is refused with 400 in either incomplete form: |
resource_ref | string | ≤ 300 chars | |
requested_atrequired | string | ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ · date-time | |
chain | object | — | |
chain.prev_hashrequired | string | — |
{
"session_external_id": "run_8812",
"seq": 3,
"tool": {
"name": "gmail.send",
"provider": "composio"
},
"args_hash": "sha256:17066b93e17871e3da709ebb76beaad795b59147ec9e6c0b36b36d0b25b5f49e",
"args_redacted": {
"to": "<email:0f367fdcc159df07>",
"subject": "Subscription updated"
},
"signals": {
"destination": "external",
"recipient_count": 1,
"data_class": "personal"
},
"resource_ref": "gmail:account_12",
"requested_at": "2026-07-13T12:35:53.000Z",
"chain": {
"prev_hash": "sha256:84c0ce3c697b3585d11022c55ae8642c4d98f68e44363f8e20a75ca3b297c54e"
}
}Responses
| Status | Meaning |
|---|---|
200 | A signed decision. Honour the verdict. |
400 | Validation failed, or a required header is missing. |
401 | Missing or invalid API key. On the ingest path this means the actions being taken right now are going unrecorded — an incident, not a retryable error. |
404 | Unknown session — open it with |
{
"action_id": "act_7d20e4",
"decision": {
"decision_id": "dec_a81f33",
"verdict": "ALLOW",
"engine": "rules",
"effect": "enforced"
},
"receipt": {
"id": "rcp_5c0b91",
"kid": "hk_2026_07",
"alg": "Ed25519",
"signature": "gRn8weW8sdqgJwov82cEAECuREfwxOEAj2YtBNCgza2q_oJ6u4c6vFAL-X5tAX7HhKxPSrGjIGToewVXvy1JBg"
},
"chain": {
"prev_hash": "sha256:84c0ce3c697b3585d11022c55ae8642c4d98f68e44363f8e20a75ca3b297c54e",
"record_hash": "sha256:c878657b0ad109505c6c8b0cbbb6a15ddd4ed9cf765c785f7af4093fe87975af"
}
}{
"error": {
"code": "bad_request",
"message": "Validation failed",
"detail": [
{
"path": "principal.ref",
"message": "principal.ref must not be an email address"
}
]
}
}{
"error": {
"code": "unauthorized",
"message": "Missing or invalid API key",
"detail": null
}
}{
"error": {
"code": "not_found",
"message": "Unknown session; open it with POST /v1/sessions first",
"detail": null
}
}| Field | Type | Constraint | Notes |
|---|---|---|---|
action_idrequired | string | — | |
decisionrequired | object | — | |
decision.decision_idrequired | string | — | |
decision.verdictrequired | string | ALLOW · DENY · STEP_UP · MODIFY · DEFER | The verdict Heron enforces. Only ALLOW runs. DENY does not; STEP_UP waits for a human; MODIFY and DEFER are honoured by submitting a new action, never by running this one. No answer at all is fail-closed by the same rule. |
decision.enginerequired | string | — | |
decision.effect | string | enforced · advisory | What to do with the verdict above. |
decision.transform | object | — | On a MODIFY: the narrowing the edge must apply before re-submitting. Advisory and unsigned — Heron never rewrites the call it does not hold. |
decision.transform.noterequired | string | — | |
decision.transform.narrow_destination_to | string | — | |
decision.transform.reduce_magnitude_to | string | — | |
decision.pending | object | — | On a DEFER: the prior context the session must carry before this call is judged again. |
decision.pending.prior_operation | array of string | — | |
decision.pending.prior_read_of_data_class | boolean | — | |
receiptrequired | object | — | Heron's signature over the decision. Verify it against the JWKS; the key is never removed from there, so an old receipt stays verifiable. |
receipt.idrequired | string | — | |
receipt.kidrequired | string | — | |
receipt.algrequired | string | Ed25519 | |
receipt.signaturerequired | string | — | |
chainrequired | object | — | |
chain.prev_hashrequired | string | ^sha256:[0-9a-f]{64}$ | |
chain.record_hashrequired | string | ^sha256:[0-9a-f]{64}$ |
POST /v1/executions
Execution evidence
What you did with the decision, under your own signature. Report it either way — BLOCKED when you honoured a DENY, ESCALATED when you paused for a human — because an action with no execution is itself a finding.
Use ABANDONED when you started the call and stopped waiting for it (a tool timeout, a cancelled request, a worker that lost its lease). Do not report that as FAILED: in most runtimes a timeout is not a cancellation, so the side effect may still have happened, and this statement is only ever a claim about your own conduct. Heron counts ABANDONED as having run — abandoning a call under a DENY is the same finding as executing it.
Use NOT_ATTEMPTED when nothing was put into the world and the reason was yours: your agent changed its mind, a person cancelled, one of your own limits refused the call. It is the honest opposite of ABANDONED, and it is what keeps a benign non-event out of MISSING_EXECUTION, which reads as a gap in your reporting. It buys you nothing against a refusal — "I did not run it" is not "I did not run it because you said no", and only the second is BLOCKED — but it is not counted as silence either: Heron publishes it in its own right, so stating it is never worse than saying nothing.
The signed bytes are JCS({action_id, decision_id, outcome, result_hash, error_code, executed_at}): the body below minus signature. Reproduce spec/test-vectors.json before sending a real one.
| Name | In | Notes |
|---|---|---|
Idempotency-Keyrequired | header | A replay returns the original result and never forks the chain. Required — a retry without it would be a second action. |
| Field | Type | Constraint | Notes |
|---|---|---|---|
action_idrequired | string | — | |
decision_idrequired | string | — | |
outcomerequired | string | EXECUTED · SKIPPED · FAILED · BLOCKED · ESCALATED · ABANDONED · NOT_ATTEMPTED | |
result_hash | string | null | — | |
error_code | string | null | — | |
executed_atrequired | string | ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ · date-time | |
signaturerequired | object | — | |
signature.kidrequired | string | — | |
signature.algrequired | string | — | |
signature.valuerequired | string | — |
{
"action_id": "act_7d20e4",
"decision_id": "dec_a81f33",
"outcome": "EXECUTED",
"result_hash": "sha256:f5482a39fbe0a49ff7962f56208024be9258aad19612e2742edc29c801aa9728",
"error_code": null,
"executed_at": "2026-07-13T12:35:54.000Z",
"signature": {
"kid": "theona_2026_07",
"alg": "Ed25519",
"value": "gRn8weW8sdqgJwov82cEAECuREfwxOEAj2YtBNCgza2q_oJ6u4c6vFAL-X5tAX7HhKxPSrGjIGToewVXvy1JBg"
}
}Responses
| Status | Meaning |
|---|---|
200 | Stored and paired with the action. |
202 | Stored, but the action is unknown to us — recorded as an anomaly rather than dropped. |
400 | Validation failed, or a required header is missing. |
401 | Missing or invalid API key. On the ingest path this means the actions being taken right now are going unrecorded — an incident, not a retryable error. |
{
"ok": true,
"signature_valid": true
}{
"ok": true,
"signature_valid": false,
"recorded": "anomaly"
}{
"error": {
"code": "bad_request",
"message": "Validation failed",
"detail": [
{
"path": "principal.ref",
"message": "principal.ref must not be an email address"
}
]
}
}{
"error": {
"code": "unauthorized",
"message": "Missing or invalid API key",
"detail": null
}
}| Field | Type | Constraint | Notes |
|---|---|---|---|
okrequired | boolean | — | |
signature_validrequired | boolean | — | False is not an error: the statement is stored and raises INVALID_VENDOR_SIGNATURE. Refusing it would destroy the evidence that something is wrong. Check this field — a |
replay | boolean | — | Present when this was a replay of an execution we already had. |
recorded | string | anomaly | Present on the |
POST /v1/sessions/{id}/close
Freeze the chain head
Accepts either Heron's session_id or your external_id.
| Name | In | Notes |
|---|---|---|
idrequired | path |
Responses
| Status | Meaning |
|---|---|
200 | Closed, or already closed. |
401 | Missing or invalid API key. On the ingest path this means the actions being taken right now are going unrecorded — an incident, not a retryable error. |
404 | Unknown session. |
{
"session_id": "ses_2f9c1a",
"head_hash": "sha256:c878657b0ad109505c6c8b0cbbb6a15ddd4ed9cf765c785f7af4093fe87975af",
"closed_at": "2026-07-13T12:36:10.000Z"
}{
"error": {
"code": "unauthorized",
"message": "Missing or invalid API key",
"detail": null
}
}{
"error": {
"code": "not_found",
"message": "Unknown session",
"detail": null
}
}| Field | Type | Constraint | Notes |
|---|---|---|---|
session_idrequired | string | — | |
head_hashrequired | string | ^sha256:[0-9a-f]{64}$ | |
closed_atrequired | string | date-time |
PUT /v1/tool-catalog
What your tools are
Your signed statement of what each tool is, so a classification does not have to be inferred from its name.
Where you send no signal, Heron derives a dimension from the tool name and publishes it as derived — which reads as an independent inference and is not one: a tool name is a string you chose, and nothing binds it to what the tool does. A catalogue is that same knowledge under your key, with a date on it, published to your reviewer in full.
Only facts that are constant for the tool belong here — op, data_class, destination, reversible. A recipient count or an amount is a fact about one call and stays in that call’s signals, which always win over the catalogue. State destination only where it really is constant for the tool.
Idempotent by content: the catalogue is canonicalised and hashed, so sending it on every process start is the intended usage and an unchanged catalogue writes nothing. The signed bytes are JCS of the canonical catalogue — the body minus signature, with tools sorted by name and absent fields omitted.
| Field | Type | Constraint | Notes |
|---|---|---|---|
toolsrequired | array of object | — | |
tools[].namerequired | string | ≤ 300 chars | |
tools[].signalsrequired | object | — | |
tools[].signals.op | string | read · write · send · delete · execute | |
tools[].signals.data_class | string | none · operational · financial · credential · personal | |
tools[].signals.destination | string | none · internal · external · third_party | |
tools[].signals.reversible | boolean | — | |
tools[].signals.reversibility | string | reversible · costly · terminal | |
tools[].provider | string | ≤ 120 chars | |
tools[].server | string | ≤ 120 chars | |
signaturerequired | object | — | |
signature.kidrequired | string | — | |
signature.algrequired | string | — | |
signature.valuerequired | string | — |
{
"tools": [
{
"name": "ATTIO_FIND_RECORD",
"signals": {
"op": "read",
"data_class": "personal",
"destination": "third_party"
},
"provider": "composio",
"server": "attio"
},
{
"name": "SESSION_MEMORY_WRITE",
"signals": {
"op": "write",
"destination": "internal"
}
}
],
"signature": {
"kid": "vendor-2026-07",
"alg": "Ed25519",
"value": "T3Jq…"
}
}Responses
| Status | Meaning |
|---|---|
200 | These exact facts were already published — nothing was written. |
201 | Stored, and now the catalogue in force. |
400 | Validation failed, or a required header is missing. |
401 | Missing or invalid API key. On the ingest path this means the actions being taken right now are going unrecorded — an incident, not a retryable error. |
{
"ok": true,
"catalog_hash": "sha256:1f0b6a0f5c1f4a6d9c0e7a3b8d2c5f1e4a7b0c3d6e9f2a5b8c1d4e7f0a3b6c9d",
"tools": 228,
"signature_valid": true,
"replay": true
}{
"ok": true,
"catalog_hash": "sha256:1f0b6a0f5c1f4a6d9c0e7a3b8d2c5f1e4a7b0c3d6e9f2a5b8c1d4e7f0a3b6c9d",
"tools": 228,
"signature_valid": true
}{
"error": {
"code": "bad_request",
"message": "Validation failed",
"detail": [
{
"path": "principal.ref",
"message": "principal.ref must not be an email address"
}
]
}
}{
"error": {
"code": "unauthorized",
"message": "Missing or invalid API key",
"detail": null
}
}| Field | Type | Constraint | Notes |
|---|---|---|---|
okrequired | boolean | — | |
catalog_hashrequired | string | — | sha256 over the canonical (RFC 8785) catalogue — the same value published on the evidence page. Sending the same facts again returns this same hash and changes nothing. |
toolsrequired | integer | — | How many tools the stored catalogue describes. |
signature_validrequired | boolean | — | False is not an error: the catalogue is stored and raises INVALID_VENDOR_SIGNATURE. A catalogue is precisely the artefact where 'signed by whom' is the question, so refusing it would delete the evidence that the answer is wrong. |
replay | boolean | — | Present when this catalogue was already published — nothing was written. |
GET /.well-known/heron-jwks.json
Heron's public keys
All of them, including retired ones — removing a key would silently invalidate every receipt it ever signed.
This is the only thing a reviewer needs from us to check a receipt: offline verification uses nothing else.
Responses
| Status | Meaning |
|---|---|
200 | The key set. |
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"kid": "hk_2026_07",
"x": "Fq3n…",
"alg": "EdDSA",
"use": "sig"
}
]
}| Field | Type | Constraint | Notes |
|---|---|---|---|
keysrequired | array of object | — | |
keys[].ktyrequired | string | OKP | |
keys[].crvrequired | string | Ed25519 | |
keys[].kidrequired | string | — | |
keys[].xrequired | string | — | base64url raw public key (RFC 8037). |
keys[].algrequired | string | EdDSA | |
keys[].userequired | string | sig |
GET /health
Liveness
Checks the two things that silently break a deploy: the database, and whether the active signing key is registered.
Unauthenticated, so a failing check names which check failed and nothing more — a probe anyone can run is not a place to publish our internals.
Responses
| Status | Meaning |
|---|---|
200 | Healthy. |
503 | A dependency is down. |
{
"status": "ok",
"checks": {
"database": "ok",
"signing_key": "ok"
}
}{
"status": "error",
"checks": {
"database": "error",
"signing_key": "ok"
}
}| Field | Type | Constraint | Notes |
|---|---|---|---|
status | string | — | |
checks | object | — |
Webhooks
Outbound: requests we make to an endpoint you register in Settings → Webhooks. They are described in the same document, so a generated client can type what it receives.
POST anomaly.detected
A finding was raised
Reconciliation found something that does not add up — a broken chain, an invalid vendor signature, an execution for an action we never saw. The finding is on the evidence page whether or not you act on this.
Signed with X-Heron-Signature: t=<unix-seconds>,v1=<hex hmac-sha256 of "<t>.<body>">, alongside X-Heron-Event. Verification rejects a stale timestamp, so a captured delivery cannot be replayed.
Five attempts with exponential backoff (1 s → 10 s → 1 m → 10 m → 1 h). Endpoints and their signing secrets live in Settings → Webhooks.
| Field | Type | Constraint | Notes |
|---|---|---|---|
eventrequired | string | anomaly.detected | |
datarequired | object | — |
{
"event": "anomaly.detected",
"data": {
"anomaly_id": "ano_31c7a0",
"kind": "INVALID_VENDOR_SIGNATURE",
"session_id": "ses_2f9c1a",
"action_id": "act_7d20e4",
"detected_at": "2026-07-13T12:36:31.000Z"
}
}Responses
| Status | Meaning |
|---|---|
2XX | Any 2xx acknowledges the delivery. Anything else is retried. |
POST evidence.published
An evidence page was published
A reviewer-facing page is live at the URL in the payload.
Signed with X-Heron-Signature: t=<unix-seconds>,v1=<hex hmac-sha256 of "<t>.<body>">, alongside X-Heron-Event. Verification rejects a stale timestamp, so a captured delivery cannot be replayed.
Five attempts with exponential backoff (1 s → 10 s → 1 m → 10 m → 1 h). Endpoints and their signing secrets live in Settings → Webhooks.
| Field | Type | Constraint | Notes |
|---|---|---|---|
eventrequired | string | evidence.published | |
datarequired | object | — |
{
"event": "evidence.published",
"data": {
"slug": "theona-billing-2026-07",
"url": "https://heron.example/e/theona-billing-2026-07",
"published_at": "2026-07-13T13:00:00.000Z"
}
}Responses
| Status | Meaning |
|---|---|
2XX | Any 2xx acknowledges the delivery. Anything else is retried. |
Errors
{
"error": {
"code": "bad_request",
"message": "Validation failed",
"detail": [
{
"path": "principal.ref",
"message": "…"
}
]
}
}401 unauthorized · 400 bad_request · 404 not_found · 409 conflict. A 401 on the ingest path means the actions being taken right now are going unrecorded — treat it as an incident, not as a retryable error.