For vendors

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.

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/actions and /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.

Request body
FieldTypeConstraintNotes
external_idrequiredstring≤ 200 chars
agentrequiredobject
agent.external_idrequiredstring≤ 200 chars
agent.namestring≤ 200 chars
agent.versionstring≤ 50 chars
principalrequiredobject
principal.typerequiredstringhuman · service
principal.refrequiredstring≤ 200 chars
original_requestrequiredobject
original_request.hashrequiredstring^sha256:[0-9a-f]{64}$
original_request.anchorsarray of string
taskobject
task.refrequiredstring≤ 200 chars
task.parent_session_external_idstring≤ 200 chars
grantobject
grant.hashrequiredstring^sha256:[0-9a-f]{64}$
grant.refstring≤ 200 chars
grant.scopeobject
grant.scope.allowed_toolsarray of string
grant.scope.boundsobject
grant.scope.bounds.operationarray of string
grant.scope.bounds.data_classarray of string
grant.scope.bounds.destinationarray of string
grant.scope.bounds.magnitudearray of string
grant.scope.bounds.reversibilityarray of string
grant.scope.expires_atstring^(?:(?:\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_atrequiredstring^(?:(?:\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
Request
{
  "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

StatusMeaning
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 external_id is being opened concurrently.

Response · 200
{
  "session_id": "ses_2f9c1a",
  "head_hash": "sha256:84c0ce3c697b3585d11022c55ae8642c4d98f68e44363f8e20a75ca3b297c54e"
}
Response · 400
{
  "error": {
    "code": "bad_request",
    "message": "Validation failed",
    "detail": [
      {
        "path": "principal.ref",
        "message": "principal.ref must not be an email address"
      }
    ]
  }
}
Response · 401
{
  "error": {
    "code": "unauthorized",
    "message": "Missing or invalid API key",
    "detail": null
  }
}
Response · 409
{
  "error": {
    "code": "conflict",
    "message": "Session already exists",
    "detail": null
  }
}
Response body
FieldTypeConstraintNotes
session_idrequiredstring
head_hashrequiredstring^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.

Parameters
NameInNotes
Idempotency-Keyrequiredheader

A replay returns the original result and never forks the chain. Required — a retry without it would be a second action.

Request body
FieldTypeConstraintNotes
session_external_idrequiredstring≤ 200 chars
seqrequiredinteger
toolrequiredobject
tool.namerequiredstring≤ 200 chars
tool.providerstring≤ 100 chars
tool.serverstring≤ 500 chars
args_redactedobject
args_hashrequiredstring^sha256:[0-9a-f]{64}$
signalsobject≤ 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.

  • op (string, feeds operation, derivable: full) — read|write|send|delete|execute. Overrides the verb regex on the tool name.
  • data_class (string, feeds data_class, derivable: partial) — operational|financial|credential|personal. personal is NEVER derived — a name cannot tell you a sheet of numbers is personal data; only the vendor can.
  • destination (string, feeds destination, derivable: none) — internal|external|third_party for a send/write. A gmail: resource does not name its recipient — undecidable from the tool name.
  • recipient_external (boolean, feeds destination, derivable: none) — Shorthand for destination when the full class is overkill. Same pinning: not derivable.
  • record_count (number, feeds magnitude, derivable: none) — How many records the action touches. Magnitude is invisible from a tool name.
  • recipient_count (number, feeds magnitude, derivable: none) — How many recipients a send reaches. Magnitude is invisible from a tool name.
  • amount (number, feeds magnitude, derivable: none) — Money moved, in minor units. Magnitude is invisible from a tool name.
  • reversible (boolean, feeds reversibility, derivable: partial) — Heron derives a default per operation (a send is terminal, a read reversible); this overrides it for the cases the heuristic gets wrong. Two-valued, so it cannot say costly — use reversibility where the honest answer is the middle one.
  • reversibility (string, feeds reversibility, derivable: partial) — The dimension in full: reversible | costly | terminal. costly is the value reversible cannot express — recovery exists but is not an undo (a record the agent can regenerate, a file restorable from a backup). Without it, a vendor whose delete is recoverable-at-a-cost must either overclaim true or accept terminal, and the rule that allows a recoverable delete is unreachable by declaration. Wins over reversible when both are sent.
  • resolves_action (string, feeds approval, derivable: none) — action_id of the STEP_UP a human answered. Only the vendor knows a person decided — never derivable.
  • human_decision (string, feeds approval, derivable: none) — APPROVE lifts the named STEP_UP; anything else is a decline (an approval must be stated, never assumed).
  • approver (string, feeds approval, derivable: none) — Opaque token for who approved — never a name (invariant #6). Enough to tell two approvers apart. Goes with resolves_action or with human_authorized.
  • human_authorized (boolean, feeds approval, derivable: none) — A human cleared this call in the vendor's own UI BEFORE Heron saw it, so it answers no STEP_UP of ours. Recorded and published; it never lifts a verdict, because unlike resolves_action there is no action of ours it can be checked against.
  • inferred (string, feeds inference, derivable: none) — Comma-separated dimension names whose value on THIS call came from a model rather than from a measurement (formatInferredDimensions). It does not carry a value of its own — it re-labels the signals sent beside it, so their source is published as inferred instead of declared. Only the vendor knows which of its own claims are testimony; nothing on our side can tell them apart, which is exactly why the fold would be permanent.
  • inference_model (string, feeds inference, derivable: none) — The model that answered, as the vendor names it. Required whenever inferred is sent — an unattributed model claim is one a reviewer can count and not question.
  • inference_prompt_hash (string, feeds inference, derivable: none) — Hash of the judging prompt the SDK put to the model — the question came from us, versioned and signed, never from the text under attack. Required whenever inferred is sent.
  • inference_slice (string, feeds inference, derivable: none) — A label for how much of the conversation the fork saw (last_turn, turn_and_plan) — never any of it. Required whenever inferred is sent.

A human's involvement is one statement, and it comes in two shapes. Answering a STEP_UP Heron issued: resolves_action names it, human_decision decides it, approver says who — and human_decision without resolves_action is rejected with 400 rather than accepted and ignored, because an approval the verdict silently did not read is worse than no approval at all. An approval your own UI collected before Heron ever saw the call answers no step-up of ours and names no action: send human_authorized. It is recorded and published, and it never lifts a verdict — there is no action of ours to check it against, and a claim that cannot be checked must not open a human gate.

A commitment is not a signal, and instructions_hash is the one key here that feeds nothing. It is your digest (sha256:<64 hex>, refused in any other shape) over the agent's instruction slot on this call — the system prompt plus whatever plan block your runtime keeps beside it. Heron never receives the text, reads no meaning from the digest, and lets it reach no rule: it is only ever compared with the last one committed in the same session, and what is published is that the instructions changed between two calls. Send it on every action or the coverage figure on your evidence page says how often you did not — a change you do not commit to is a change nobody can see, and the page states that in those words.

shown_text_hash is the second such commitment, and it belongs on the action carrying a human's answer. It is your digest (sha256:<64 hex>, refused in any other shape) over the bytes your confirmation UI actually rendered to that person. Nothing else in the record says what they were looking at, so an approval given to a prompt that described the call and one given to "continue?" are indistinguishable today; with the commitment you cannot restate afterwards what you showed. It feeds no dimension and no rule can turn on it, for the reason the instruction commitment cannot: a value you choose that also moved a verdict would make omitting it the cheapest way out. Your evidence page publishes only that you committed, never the digest — a confirmation prompt names a person, so derive it with the same secret that keys your anchors and it confirms nothing to a stranger while still proving everything it is for.

A model's claim is one statement too, in four parts, and it is refused with 400 in either incomplete form: inferred without inference_model/inference_prompt_hash/inference_slice is a marking a reviewer can count and cannot question, and those three without inferred are provenance for a claim nobody made. inferred carries no value of its own — it names the dimensions (never the signal keys) whose value beside it came from a model, so their source is published as inferred rather than declared. Because it names dimensions, there is no spelling of it that marks an approval: a model can never sign off on its own step-up. And it can only ever answer a dimension nothing else determined — a model's word never displaces the tool catalogue, and never displaces Heron's own derivation from the tool name.

resource_refstring≤ 300 chars
requested_atrequiredstring^(?:(?:\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
chainobject
chain.prev_hashrequiredstring
Request
{
  "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

StatusMeaning
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 POST /v1/sessions first.

Response · 200
{
  "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"
  }
}
Response · 400
{
  "error": {
    "code": "bad_request",
    "message": "Validation failed",
    "detail": [
      {
        "path": "principal.ref",
        "message": "principal.ref must not be an email address"
      }
    ]
  }
}
Response · 401
{
  "error": {
    "code": "unauthorized",
    "message": "Missing or invalid API key",
    "detail": null
  }
}
Response · 404
{
  "error": {
    "code": "not_found",
    "message": "Unknown session; open it with POST /v1/sessions first",
    "detail": null
  }
}
Response body
FieldTypeConstraintNotes
action_idrequiredstring
decisionrequiredobject
decision.decision_idrequiredstring
decision.verdictrequiredstringALLOW · 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.enginerequiredstring
decision.effectstringenforced · advisory

What to do with the verdict above. enforced — honour it before executing. advisory — the project has declared a shadow window, so record the verdict and run anyway; the execution that follows is published as a rehearsal, not a breach. Read this rather than carrying a setting of your own: the mode is declared by an operator on our side, dated and signed into the receipt (enforcement.effect), so there is no second source for it to disagree with. Absent on a decision issued before this field existed, and absent means enforced.

decision.transformobject

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.noterequiredstring
decision.transform.narrow_destination_tostring
decision.transform.reduce_magnitude_tostring
decision.pendingobject

On a DEFER: the prior context the session must carry before this call is judged again.

decision.pending.prior_operationarray of string
decision.pending.prior_read_of_data_classboolean
receiptrequiredobject

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.idrequiredstring
receipt.kidrequiredstring
receipt.algrequiredstringEd25519
receipt.signaturerequiredstring
chainrequiredobject
chain.prev_hashrequiredstring^sha256:[0-9a-f]{64}$
chain.record_hashrequiredstring^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.

Parameters
NameInNotes
Idempotency-Keyrequiredheader

A replay returns the original result and never forks the chain. Required — a retry without it would be a second action.

Request body
FieldTypeConstraintNotes
action_idrequiredstring
decision_idrequiredstring
outcomerequiredstringEXECUTED · SKIPPED · FAILED · BLOCKED · ESCALATED · ABANDONED · NOT_ATTEMPTED
result_hashstring | null
error_codestring | null
executed_atrequiredstring^(?:(?:\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
signaturerequiredobject
signature.kidrequiredstring
signature.algrequiredstring
signature.valuerequiredstring
Request
{
  "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

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

Response · 200
{
  "ok": true,
  "signature_valid": true
}
Response · 202
{
  "ok": true,
  "signature_valid": false,
  "recorded": "anomaly"
}
Response · 400
{
  "error": {
    "code": "bad_request",
    "message": "Validation failed",
    "detail": [
      {
        "path": "principal.ref",
        "message": "principal.ref must not be an email address"
      }
    ]
  }
}
Response · 401
{
  "error": {
    "code": "unauthorized",
    "message": "Missing or invalid API key",
    "detail": null
  }
}
Response body
FieldTypeConstraintNotes
okrequiredboolean
signature_validrequiredboolean

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 200 alone does not mean your signature was accepted.

replayboolean

Present when this was a replay of an execution we already had.

recordedstringanomaly

Present on the 202: the statement named an action we never saw, so it is kept as a finding rather than dropped.

POST /v1/sessions/{id}/close

Freeze the chain head

Accepts either Heron's session_id or your external_id.

Parameters
NameInNotes
idrequiredpath

Responses

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

Response · 200
{
  "session_id": "ses_2f9c1a",
  "head_hash": "sha256:c878657b0ad109505c6c8b0cbbb6a15ddd4ed9cf765c785f7af4093fe87975af",
  "closed_at": "2026-07-13T12:36:10.000Z"
}
Response · 401
{
  "error": {
    "code": "unauthorized",
    "message": "Missing or invalid API key",
    "detail": null
  }
}
Response · 404
{
  "error": {
    "code": "not_found",
    "message": "Unknown session",
    "detail": null
  }
}
Response body
FieldTypeConstraintNotes
session_idrequiredstring
head_hashrequiredstring^sha256:[0-9a-f]{64}$
closed_atrequiredstringdate-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.

Request body
FieldTypeConstraintNotes
toolsrequiredarray of object
tools[].namerequiredstring≤ 300 chars
tools[].signalsrequiredobject
tools[].signals.opstringread · write · send · delete · execute
tools[].signals.data_classstringnone · operational · financial · credential · personal
tools[].signals.destinationstringnone · internal · external · third_party
tools[].signals.reversibleboolean
tools[].signals.reversibilitystringreversible · costly · terminal
tools[].providerstring≤ 120 chars
tools[].serverstring≤ 120 chars
signaturerequiredobject
signature.kidrequiredstring
signature.algrequiredstring
signature.valuerequiredstring
Request
{
  "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

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

Response · 200
{
  "ok": true,
  "catalog_hash": "sha256:1f0b6a0f5c1f4a6d9c0e7a3b8d2c5f1e4a7b0c3d6e9f2a5b8c1d4e7f0a3b6c9d",
  "tools": 228,
  "signature_valid": true,
  "replay": true
}
Response · 201
{
  "ok": true,
  "catalog_hash": "sha256:1f0b6a0f5c1f4a6d9c0e7a3b8d2c5f1e4a7b0c3d6e9f2a5b8c1d4e7f0a3b6c9d",
  "tools": 228,
  "signature_valid": true
}
Response · 400
{
  "error": {
    "code": "bad_request",
    "message": "Validation failed",
    "detail": [
      {
        "path": "principal.ref",
        "message": "principal.ref must not be an email address"
      }
    ]
  }
}
Response · 401
{
  "error": {
    "code": "unauthorized",
    "message": "Missing or invalid API key",
    "detail": null
  }
}
Response body
FieldTypeConstraintNotes
okrequiredboolean
catalog_hashrequiredstring

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.

toolsrequiredinteger

How many tools the stored catalogue describes.

signature_validrequiredboolean

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.

replayboolean

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

StatusMeaning
200

The key set.

Response · 200
{
  "keys": [
    {
      "kty": "OKP",
      "crv": "Ed25519",
      "kid": "hk_2026_07",
      "x": "Fq3n…",
      "alg": "EdDSA",
      "use": "sig"
    }
  ]
}
Response body
FieldTypeConstraintNotes
keysrequiredarray of object
keys[].ktyrequiredstringOKP
keys[].crvrequiredstringEd25519
keys[].kidrequiredstring
keys[].xrequiredstring

base64url raw public key (RFC 8037).

keys[].algrequiredstringEdDSA
keys[].userequiredstringsig

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

StatusMeaning
200

Healthy.

503

A dependency is down.

Response · 200
{
  "status": "ok",
  "checks": {
    "database": "ok",
    "signing_key": "ok"
  }
}
Response · 503
{
  "status": "error",
  "checks": {
    "database": "error",
    "signing_key": "ok"
  }
}
Response body
FieldTypeConstraintNotes
statusstring
checksobject

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.

Request body
FieldTypeConstraintNotes
eventrequiredstringanomaly.detected
datarequiredobject
Request
{
  "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

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

Request body
FieldTypeConstraintNotes
eventrequiredstringevidence.published
datarequiredobject
Request
{
  "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

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