API reference
Complete endpoint reference for the mnd8t authority API. For a narrative
introduction, read the developer guide first.
Interactive OpenAPI — generated from the route schemas, always in sync with
the running build — is served at /docs on your API host.
Base path: /v1 · Content type: application/json ·
All money: integer minor units · All timestamps: ISO 8601 UTC
Conventions
Authentication
Authorization: Bearer mdt_test_...
API keys are scoped. A key may do exactly what its scopes permit and nothing else, within its own organisation.
| Scope | Grants |
|---|---|
decisions:read / decisions:write |
read decisions; submit, confirm, cancel |
artifacts:read / artifacts:write |
read artifacts; claim, fail, reissue |
receipts:read |
read evidence receipts |
registry:read |
read agents, counterparties, mandates, accounts |
agents:write |
create and modify agents |
counterparties:write |
create and modify counterparties |
accounts:write |
create and modify financial accounts and bindings |
mandates:write |
create and edit draft mandates |
mandates:publish |
publish and revoke — the act that grants authority |
webhooks:write |
manage webhook endpoints |
valuations:assert |
state what an action is worth — not in the AGENT preset |
evidence:submit |
submit customer-asserted outcome evidence after execution — not in the AGENT preset |
keys:write |
create and revoke API keys |
approvals:act |
approve or reject escalations |
Two presets: AGENT — the first six, everything needed to transact and
nothing that can change what is permitted — and ADMIN, all fourteen.
POST /v1/api-keys defaults to AGENT.
Give an agent an AGENT key. An ADMIN key can add a counterparty and
publish a mandate, so an agent holding one can rewrite the authority it is
about to be evaluated against. A key also cannot mint a key wider than
itself.
Dashboard requests authenticate with a signed session cookie instead. People
carry an organisation role (ADMIN / APPROVER / VIEWER), not scopes; the
role each endpoint needs is noted below, and is unchanged by scoping.
Public exceptions (no credential): the approval-link endpoints, the key discovery document, and the health endpoints.
Idempotency
All write endpoints accept Idempotency-Key. POST /v1/decision-intents
requires it (8–200 characters).
| Situation | Result |
|---|---|
| Same key, identical payload | Original response replayed |
| Same key, different payload | 409 IDEMPOTENCY_CONFLICT |
| Same key, first still in flight | 409 CONFLICT |
Records are retained 7 days.
Errors
{ "error": { "code": "VALIDATION_FAILED", "message": "…", "details": null } }
UNAUTHENTICATED 401 · FORBIDDEN 403 · NOT_FOUND 404 ·
IDEMPOTENCY_CONFLICT / CONFLICT 409 · VALIDATION_FAILED /
UNSUPPORTED_ACTION_TYPE 422 · RATE_LIMITED 429 · INTERNAL 500.
Resources belonging to another organisation return 404, never 403.
Rate limits
300 requests/minute globally; 120/min on the decision endpoint; 10–60/min on
authentication, approval actions, and artifact claims. Exceeding a limit
returns 429.
Decisions
POST /v1/decision-intents
Submit a proposed financial action for an authority decision. This is the endpoint. Call it before executing, never after.
Headers: Authorization, Idempotency-Key (required)
Body
| Field | Type | Notes |
|---|---|---|
agent_external_id |
string | Required. The agent's external_id. |
action_type |
enum | PAYMENT | TRANSFER. Default PAYMENT. Anything else → 422 UNSUPPORTED_ACTION_TYPE. |
amount |
object | { asset, asset_amount_minor }. Preferred form. |
valuation |
object | { policy_amount_minor, policy_currency, source?, rate_reference?, valued_at? }. The value the engine evaluates. |
amount_minor, policy_amount_minor, policy_currency, asset |
— | Deprecated flat form; still accepted. |
counterparty_external_id |
string | Resolved against your counterparty registry. |
destination |
string | Wallet address or provider reference. |
purpose |
string | Checked against allowed_purposes. |
financial_account_id |
string | The account the action would draw on. |
external_reference |
string | Your invoice/order id. |
occurred_at |
string | ISO 8601, informational only: recorded on the intent and its receipts. Policy evaluation — schedules and budget periods — always uses server time, so a caller-supplied timestamp cannot move a decision into a different schedule window or budget period. Defaults to server time. |
jurisdiction_context |
object | { principal_country?, provider_country?, counterparty_country?, customer_classification? }. Evidence only — never affects the decision. |
identity_attestation |
object | External identity / session attestation from an admission layer such as AFSP: { protocol: "AFSP" | "OTHER", protocol_version, issuer, clearing_institution?, agent_id, principal_id?, session_id, package_id?, clearance_decision?, action_classes?, not_before?, not_after, single_use?, token_digest? }, named after the AFSP v0.1 session token. token_digest is sha256:<hex> of the raw token, which you should not send. Recorded in every receipt for the decision; checked only when the mandate has an identity_attestation section, and can only refuse. Not verified by mnd8t. See policy-model.md. |
ap2 |
object | AP2 v0.2.0 mandates: { version, payment_mandate, checkout_mandate }, each a compact SD-JWT chain. See AP2 mandate input. When present, the amount, currency and merchant come from the mandates: sending amount, amount_minor, asset or counterparty_external_id as well is 422. |
metadata |
object | Free-form; stored and echoed in evidence. |
mnd8t never fetches exchange rates. Supply policy_amount_minor already
normalised into the mandate's currency, with provenance.
201 — enforce mode, approved
{
"id": "int_9k2m4x8q1z7c4b6n0p5v",
"mode": "ENFORCE",
"decision": "APPROVE",
"effective_decision": "APPROVE",
"would_decide": "APPROVE",
"reason_codes": ["APPROVED_WITHIN_MANDATE"],
"mandate_id": "mnd_3f8k2m9x",
"mandate_version": 3,
"approval": null,
"authorisation_artifact": {
"id": "art_7bd910442f8e",
"status": "ISSUED",
"expires_at": "2026-08-02T15:00:00.000Z",
"download_url": "/v1/authorisation-artifacts/art_7bd910442f8e"
},
"evidence_receipt_id": "evd_2m8q4x9k1z7c",
"authority_record": {
"receipt_id": "evd_2m8q4x9k1z7c",
"receipt_version": "1.1",
"action_hash": "sha256:…",
"key_id": "key_2026_01",
"url": "/v1/evidence-receipts/evd_2m8q4x9k1z7c",
"verify_url": "/v1/evidence-receipts/verify"
}
}
Every decision, whatever its outcome or mode, returns authority_record:
the signed record it produced and the digest of the action that record
binds. evidence_receipt_id is the same id, kept for existing integrations.
See evidence-receipts.md.
201 — escalated. decision: "ESCALATE", authorisation_artifact: null,
and an approval block carrying { id, status: "PENDING", expires_at, approval_url }. The artifact is issued only once a human approves. When the
deployment has an email provider, approval_url is also emailed to each
admin and approver in the organisation, once per escalation (a replay with the
same Idempotency-Key sends nothing). Anyone holding the link can approve.
201 — shadow mode. effective_decision: "OBSERVE", would_decide set
to what enforcement would have done, SHADOW_ONLY appended to
reason_codes, no approval, no artifact, no budget consumed.
An artifact is issued for approved enforce-mode decisions only — never for rejections, unresolved escalations, or shadow decisions.
GET /v1/decision-intents
List decisions, newest first.
Query: status, decision, agent_external_id, limit (1–200,
default 50), offset.
GET /v1/decision-intents/{id}
One decision with everything attached: the rule-by-rule evaluations trace,
approvals, executions, artifacts, receipts, and the agent.
Use the trace to show a human exactly which rule fired:
{
"evaluations": [
{ "rule_order": 12, "rule": "absolute_limit", "passed": true, "reason_code": null },
{ "rule_order": 16, "rule": "autonomous_threshold", "passed": false, "reason_code": "HUMAN_APPROVAL_REQUIRED" }
]
}
POST /v1/decision-intents/{id}/confirm
Record execution reported by your executor or provider. mnd8t does not execute; this endpoint evidences what you tell it.
{
"provider": "SAFE_TESTNET",
"external_reference": "0xabc…",
"authorisation_artifact_id": "art_7bd910442f8e",
"financial_account_id": "fac_1m8q4x9k",
"execution_status": "CONFIRMED",
"executed_at": "2026-08-01T14:06:00Z",
"reported_by": "CUSTOMER_EXECUTOR",
"verification": { "method": "PROVIDER_LOOKUP", "verified": true, "verified_at": "2026-08-01T14:06:05Z" }
}
authorisation_artifact_id is required for enforce-mode intents; the
artifact must belong to this intent and be ISSUED, CLAIMED or
RECONCILIATION_REQUIRED. ISSUED and CLAIMED artifacts must also be
unexpired; a RECONCILIATION_REQUIRED one is past its expiry by definition
and is accepted so a late execution can still be reported. reported_by ∈ CUSTOMER_EXECUTOR | CUSTOMER_API |
PROVIDER_WEBHOOK | MANDATE_RECONCILIATION. verification.method ∈
CUSTOMER_ASSERTED | PROVIDER_LOOKUP | PROVIDER_WEBHOOK |
NETWORK_LOOKUP.
Consumes the budget reservation exactly once and moves the artifact to
EXECUTED. Repeating with the same external_reference returns 200 with
duplicate: true.
POST /v1/decision-intents/{id}/cancel
{ "reason": "Supplier invoice withdrawn" } — releases the reservation,
expires any pending approval, and cancels live artifacts. Valid from
APPROVED, PENDING_APPROVAL, or OBSERVED.
Authorisation artifacts
The signed, single-use token that lets your system execute an approved action. See developer guide §6.
GET /v1/authorisation-artifacts/{id}
{
"id": "art_7bd910442f8e",
"decision_intent_id": "int_9k2m4x8q1z7c4b6n0p5v",
"financial_account_id": "fac_1m8q4x9k",
"status": "ISSUED",
"artifact": {
"artifact_version": "1.0",
"artifact_id": "art_7bd910442f8e",
"decision_intent_id": "int_9k2m4x8q1z7c4b6n0p5v",
"organisation_id": "org_2f8c1b7d9e4a",
"agent_id": "agt_5h3n8s2w7q1m",
"mandate_id": "mnd_8w2r5t9y4u1o",
"mandate_version": 3,
"financial_account_id": "fac_1m8q4x9k",
"action_type": "PAYMENT",
"asset": "USDC",
"amount_minor": 84000000,
"policy_amount_minor": 50000,
"policy_currency": "GBP",
"destination": "0x…",
"counterparty_reference": "supplier_acme_cloud",
"external_client_reference": "invoice_9834",
"nonce": "…",
"issued_at": "…", "not_before": "…", "expires_at": "…",
"signature": { "algorithm": "Ed25519", "key_id": "key_2026_01", "value": "base64…" }
},
"artifact_hash": "sha256:…",
"key_id": "key_2026_01",
"claimed_at": null,
"executed_at": null
}
The signature covers every field of artifact except signature itself, in
canonical form. An executor can therefore verify the complete execution
context — organisation, agent, mandate version, account, amounts, destination,
counterparty and validity window — offline, before acting.
Verify artifact locally before acting on it. The signature covers account,
action type, asset, amount, destination, counterparty, nonce and expiry.
Status: ISSUED → CLAIMED → EXECUTED, or EXPIRED / REVOKED /
CANCELLED / FAILED.
A CLAIMED artifact that passes its expiry with no confirm or fail becomes
RECONCILIATION_REQUIRED rather than EXPIRED — an executor took it, so
whether the money moved is unknown rather than known not to have. Its budget
reservation stays held (UNRECONCILED) until /confirm or /fail resolves
it; /cancel returns 409 until then. See
the policy model for the full table.
POST /v1/authorisation-artifacts/{id}/claim
{ "executor_reference": "executor-instance-1" }
Atomic single claim. Concurrent claimants: exactly one gets 200; the rest
get 409. Already-claimed, expired, revoked and cancelled artifacts all
409. Claim before executing.
POST /v1/authorisation-artifacts/{id}/fail
{ "reason_code": "PROVIDER_REJECTED", "external_reference": null, "reported_by": "CUSTOMER_EXECUTOR" }
Marks the artifact FAILED — the call that reports the money did not move,
and the one failure path an executor needs. An UNRECONCILED hold is released
here; an active reservation is deliberately left in place so you can
reissue-artifact and retry. Pair it with …/cancel only when you are
abandoning the action, which is terminal.
POST /v1/decision-intents/{id}/reissue-artifact
Issues a fresh artifact after a failure or expiry. Re-checks live state
first — revoked mandate, expired mandate, or released reservation all 409,
and an already-active artifact 409. The decision is not re-evaluated
against budgets; if the reservation is gone, submit a new intent.
Outcomes
An approved payment and a fulfilled mandate are different results. Everything above records the first: that an action was within the delegated authority at the moment it was taken. These endpoints evaluate the objective — the thing the authority was delegated for — against customer-asserted evidence.
There is no execution resource here because there already is one: an authorisation artifact is claimed, then confirmed or failed, and that is what execution means in this API. Evidence and outcomes nest under the decision and the mandate they belong to.
Outcome evidence is customer-asserted, and is a different thing from the
signed evidence receipts further down: a receipt is mnd8t's own proof of a
decision it made, while this is your record of what happened next. mnd8t does
not fetch it from a carrier, a merchant, a bank or an ERP, and does not
independently verify it; every response says
"evidence_source": "CUSTOMER_ASSERTED" rather than implying a verification
that does not happen.
To verify an outcome, the mandate's policy must carry an objective — what
the delegation was for, alongside the limits that bound it. It is frozen into
the published version like everything else in the policy, so the question
cannot be rewritten once the evidence is in, and evaluate() never reads it:
no payment is ever approved or refused because of an objective.
{
"objective": {
"description": "100 units of SKU-4410 before the Q4 launch",
"expected_quantity": 100,
"quantity_unit": "units",
"max_total_minor": 1000000,
"deadline": "2026-09-30T23:59:59Z",
"counterparty_id": "supplier_acme_cloud",
"required_evidence": ["merchant_receipt"]
}
}
An expectation implies the evidence that could test it, so expected_quantity
and deadline require delivery evidence and max_total_minor requires
settlement evidence whether or not required_evidence names them. A limit
nobody can ever check is not a limit.
POST /v1/decision-intents/{id}/evidence
Requires evidence:submit, which the AGENT preset does not carry. An
agent that can report a hundred units arrived can declare its own mandate
fulfilled, which is the same circularity scoping exists to break.
{
"type": "goods_receipt",
"observed_at": "2026-09-28T09:00:00Z",
"quantity": 80,
"counterparty_external_id": "supplier_acme_cloud",
"reference": "GRN-4410-02"
}
type is one of payment_settlement (what left the account),
merchant_receipt (what the counterparty says it sold), goods_receipt
(what was actually received) or service_attestation. They are not
interchangeable: quantity is counted from delivery evidence only, because a
supplier's own invoice is its account of the transaction, not proof that
anything arrived.
observed_at is when the reported fact occurred, not when you filed it —
a delivery note submitted late still evidences an on-time delivery.
reference is your identifier for the fact. Re-posting the same type and
reference against the same decision returns the existing record rather than
creating a second one, so retries are safe. 409 if the decision was
rejected: there is no execution to evidence.
The body also accepts amount_minor and currency (what was settled, used
by max_total_minor) and payload, a free-form object stored with the
record and never evaluated.
Mapping common sources to evidence
Choose the type by what the fact is, not by where it came from. The same system can produce two different kinds of fact: a card provider reports what was paid, never what arrived.
| What you hold | Type | Carry |
|---|---|---|
A card, bank or on-chain record that the money left — for example a Stripe Issuing issuing_transaction.created event, a bank statement line, a confirmed stablecoin transfer |
payment_settlement |
amount_minor, currency, counterparty_external_id; observed_at = when it settled; reference = the transaction id or hash |
| The counterparty's own account of the sale — a Shopify store's order confirmation, a supplier invoice, an invoice issued to you through Stripe | merchant_receipt |
reference = the order or invoice number; observed_at = the order or invoice date |
| Your own record of what arrived — a warehouse goods-received note, a signed delivery note | goods_receipt |
quantity counted on receipt; observed_at = when it arrived; reference = the note's number |
| A carrier's "delivered" tracking event | goods_receipt |
observed_at = the delivery timestamp; reference = the tracking number; no quantity — a carrier counts parcels, not what is inside them; counterparty_external_id, if set, is the supplier, not the carrier |
| Confirmation that a service was performed — licences provisioned, a signed-off timesheet | service_attestation |
quantity in the objective's unit; observed_at = when it was performed |
A Stripe Issuing capture, as an example:
{
"type": "payment_settlement",
"observed_at": "2026-09-27T16:42:10Z",
"amount_minor": 970000,
"currency": "GBP",
"counterparty_external_id": "supplier_acme_cloud",
"reference": "ipi_1Q2w3E4r5T6y",
"payload": { "source": "stripe.issuing_transaction.created" }
}
Rules that hold whichever source you use:
- Submit from your backend, under a key holding
evidence:submit— typically the webhook handler that already receives the source event. Never from the agent's runtime. amount_minoris in the mandate's currency, already converted. mnd8t sums it exactly as sent; it recordscurrencybut does not convert it or reject a mismatch. Issuing providers may report a capture as a negative amount — submit the absolute value.- Put
quantityon one record per delivery. Quantities on delivery evidence are summed, so a carrier event and a goods-received note for the same parcel must not both carry the count. The note, which somebody counted, should. - Every delivery record counts toward
deadline. The latestobserved_atamong them is taken as the delivery time, so date a goods-received note by when the goods arrived, not when they were counted. - A named counterparty must be the objective's. When the objective sets
counterparty_id, any record whosecounterparty_external_iddiffers fails the outcome withEVIDENCE_COUNTERPARTY_MISMATCH; leave it unset rather than name a carrier, bank or marketplace. - Always set
reference. It is the only thing that deduplicates; a webhook retried without one is counted twice. observed_atis the source's event time, not the time your handler ran.payloadis for your own audit trail. Attach the source event's id or a trimmed copy if you want it kept with the record. It is stored, never evaluated — and it is stored, so leave out secrets and personal data you would not otherwise keep.
GET /v1/decision-intents/{id}/evidence
Everything recorded against one authorised action, oldest observation first.
GET /v1/mandates/{id}/outcome
The objective on the mandate's published version, evaluated against every piece of evidence recorded under it.
{
"mandate_id": "mnd_...",
"mandate_version": 1,
"outcome": "NOT_FULFILLED",
"reasons": ["QUANTITY_SHORT"],
"checks": [
{ "check": "quantity", "expected": ">= 100 units", "observed": "80 units", "passed": false },
{ "check": "total_amount", "expected": "<= 1000000 minor units", "observed": "970000 minor units", "passed": true }
],
"required_evidence": ["payment_settlement", "merchant_receipt", "goods_receipt"],
"missing_evidence": [],
"evidence_source": "CUSTOMER_ASSERTED"
}
FULFILLED and NOT_FULFILLED are conclusions. PENDING_EVIDENCE is the
absence of one — the evidence that would settle the question has not been
supplied, and the objective is not failed merely because nothing has been
reported yet. A failure already visible in the evidence submitted is decisive
even while other evidence is outstanding: no later delivery note turns eighty
units into a hundred.
The example above is the case worth understanding. Every payment under that mandate was correctly approved — inside the per-transaction limit, inside the budget, to an approved counterparty — and the mandate was still not fulfilled. That is why the question is asked separately.
409 if the mandate has no published version, or if its policy declares no
objective.
Mandates
GET /v1/mandate-templates
Starter policies: procurement, treasury, paid_api, blank.
POST /v1/mandates · ADMIN
{ "agent_ids": ["agt_…", "agt_…"], "name": "Cloud procurement mandate", "mode": "SHADOW", "policy": { } }
Creates a draft at version 1. Invalid policies are rejected 422 with
per-field details. Policy schema: policy-model.md.
Name the agents with agent_ids (1 to 20, in your order, repeats dropped),
or a single agent with agent_id; give exactly one of the two. Every agent
must belong to your organisation. Agents under one mandate act under one
delegation:
- One budget. Their decisions draw on the same daily and monthly budget periods. The periods are locked for each decision, so agents deciding at the same moment cannot together exceed a limit.
- Any rail. A decision's reservation counts against the budget whether it is executed through an authorisation artifact or projected as an AP2 mandate.
- One revocation. Revoking the mandate stops every agent on it.
- Their own status. Suspending one agent (
PATCH /v1/agents/{id}) stops that agent only.
The response, and every mandate read, carries agent_ids; agent_id is
the first of them.
A published mandate cannot be changed. To change an agent's authority, create and publish a new mandate, then revoke the old one. The new mandate is a new delegation with a fresh budget: spend under the old mandate is not counted against it, even in the same day or month.
POST /v1/mandates/{id}/publish · ADMIN
Freezes the draft and makes it live. Requires a delegator attestation:
{
"change_note": "Initial cloud procurement authority",
"delegation": {
"delegator_role": "Finance Director",
"authority_source": "CORPORATE_POLICY",
"source_reference": "DoA-2026-v4-section-8",
"attested": true
}
}
mnd8t records this assertion in the evidence chain. It does not verify
that the delegator legally held the authority. A mandate is published once;
publishing it again returns 409. Returns { mandate_id, version, policy_hash, published_at }.
PATCH /v1/mandates/{id} · ADMIN
Edits the name, mode, policy or agent_ids of a mandate that has not yet
been published. The agents, like the policy, are fixed on publish. A published mandate is final and returns 409: to change what an
agent may do, create a new mandate, publish it, and revoke the old one. An
agent is evaluated against its most recently published, unrevoked mandate, so
drafting a replacement does not disturb the live one.
POST /v1/mandates/{id}/revoke · ADMIN
Immediate kill switch. New decisions reject with MANDATE_REVOKED, pending
approvals expire, unconsumed reservations release, and live artifacts are
revoked. Returns counts of each.
POST /v1/mandates/{id}/simulate
Dry run. Nothing is persisted, no budget moves.
{ "amount_minor": 120000, "asset": "USDC", "counterparty_external_id": "supplier_acme_cloud", "purpose": "software", "at": "2026-08-04T14:00:00Z", "version": 2 }
Returns { would_decide, reason_codes, trace }. at lets you test schedule
rules; version targets a specific version, including an unpublished draft.
agent_external_id simulates as one of the mandate's agents (default: its
first), so that agent's status and attestation checks apply; an agent not on
the mandate returns 404.
identity_attestation takes the same object as a live decision, so a mandate
with attestation requirements is exercised as it would be live.
ap2 takes the same object as a live decision, in place of amount_minor,
asset and counterparty_external_id; it is read by the same adapter, keys
and loader as the live path, and the response adds the same ap2 block.
GET /v1/mandates · GET /v1/mandates/{id}
List, or fetch one with its full versions array (policy, policy_hash,
published_at, change_note).
Agents
POST /v1/agents · ADMIN
{
"name": "Procurement Agent",
"external_id": "procurement_agent_12",
"purpose": "Purchase approved software and cloud services",
"principal_type": "ORGANISATION",
"principal_reference": "acme_ltd",
"metadata": {}
}
external_id matches [a-zA-Z0-9_.-]+, unique within the organisation
(duplicate → 409), and is what you pass on every decision.
GET /v1/agents · GET /v1/agents/{id}
The detail view adds mandates and the ten most recent decisions.
PATCH /v1/agents/{id} · ADMIN
Update name, purpose, metadata, or status
(ACTIVE | SUSPENDED | REVOKED). A non-active agent cannot receive an
approved decision — this is the per-agent kill switch.
Counterparties
POST /v1/counterparties · ADMIN
{
"external_id": "supplier_acme_cloud",
"display_name": "Acme Cloud",
"authority_status": "CUSTOMER_APPROVED",
"destination": "0x…",
"type": "supplier",
"category": "cloud_infrastructure",
"external_checks": [
{ "type": "SANCTIONS", "provider": "customer_supplied", "status": "PASSED", "checked_at": "…", "reference": "check_123" }
]
}
authority_status ∈ CUSTOMER_APPROVED | CUSTOMER_PENDING |
CUSTOMER_BLOCKED. The naming is deliberate: this is your assertion of
who you authorise, not a compliance verdict. external_checks results are
stored as supplied — mnd8t performs no screening and certifies none.
The legacy status field remains accepted and is returned as a deprecated
alias.
GET /v1/counterparties · PATCH /v1/counterparties/{id} (ADMIN)
Financial accounts
Provider-neutral references to accounts you control.
POST /v1/financial-accounts · ADMIN
{
"type": "SIMULATED",
"provider": "SIMULATED",
"external_reference": "sim_account_demo_1",
"network": null,
"asset_capabilities": ["USDC"],
"metadata": {}
}
type ∈ SIMULATED | SELF_HOSTED_WALLET | CUSTODIAL_WALLET |
SMART_ACCOUNT | TOKENISED_DEPOSIT_ACCOUNT | BANK_ACCOUNT | OTHER.
mnd8t stores the reference only — never credentials or keys.
GET /v1/financial-accounts · PATCH /v1/financial-accounts/{id} (ADMIN)
POST /v1/mandates/{id}/account-bindings · ADMIN · GET …
{ "financial_account_id": "fac_…", "expires_at": "…" } — one mandate may
govern several accounts.
Approvals
GET /v1/approval-actions/{token} · public
Context for the approval page, reachable without an account: agent,
principal, amount, counterparty, purpose, triggered reasons, remaining
budget per period, mandate expiry, and recent related decisions. Also
reports expired.
POST /v1/approval-actions/{token}/approve · public
POST /v1/approval-actions/{token}/reject · public
{ "comment": "Approved — invoice checked against PO 4471" }
Single use and expiring. Reuse → 409; expired → 409. Approving issues the
authorisation artifact (returned as authorisation_artifact), confirms the
budget reservation, and writes a new receipt. Rejecting releases the
reservation.
An approval authorises one action. It never changes the mandate.
GET /v1/approvals · APPROVER
Organisation-wide list; filter by status (PENDING | APPROVED |
REJECTED | EXPIRED).
Evidence receipts
Not to be confused with the outcome evidence above. A receipt is mnd8t's own signed record of a decision it made; outcome evidence is the customer's record of what happened afterwards. One is signed by us and provable without us; the other is asserted by you.
GET /v1/evidence-receipts/{id}
Returns the signed receipt, its hash, key_id, and the previous receipt
link. Prefer verifying locally — that is the point of the signature.
GET /v1/evidence-receipts/{id}/verify
Server-side check of a stored receipt: { id, valid, reason, key_id, key_status, signature_valid, record_integrity, action_hash }. Convenience
only. Checks against every published key, so receipts signed before a key
rotation still verify.
POST /v1/evidence-receipts/verify · public
Verifies a record someone handed you. No credential is required; with one,
see current_authority below.
{
"receipt": { "receipt_version": "1.1", "receipt_id": "evd_…", "…": "…", "signature": { "…": "…" } },
"expected_action": {
"action_type": "PAYMENT", "asset": "GBP", "amount_minor": 500000,
"counterparty": "supplier_a", "financial_account_id": null, "destination": null,
"purpose": "invoice_payment", "external_reference": "INV-123"
}
}
receipt may be the bare record or the GET /v1/evidence-receipts/{id}
envelope. expected_action is optional and strict: an unknown field is a
422, not a silent mismatch.
{
"valid": true,
"signature_valid": true,
"record_integrity": true,
"action_match": true,
"reason": null,
"key_id": "key_2026_01",
"key_status": "ACTIVE",
"receipt_id": "evd_…",
"receipt_version": "1.1",
"action_hash": "sha256:…",
"decision": { "status": "APPROVE", "effective_status": "APPROVE", "mode": "ENFORCE", "reasons": ["APPROVED_WITHIN_MANDATE"], "decided_at": "…" },
"mandate_reference": { "id": "mnd_…", "version": 3, "policy_hash": "sha256:…" },
"current_authority": { "status": "CHECKED", "mandate_status": "REVOKED", "current_version": 3, "recorded_version_is_current": true, "expires_at": "…", "expired": false, "revoked_at": "…", "checked_at": "…", "note": "…" }
}
signature_valid: the signature checks against the published key named by the record.record_integrity: the record'saction_hashis the digest of the action it records.nullfor a 1.0 receipt, which has none.action_match: yourexpected_actionhashes to the record'saction_hash.nullwhen you sent none.valid: all of the above that apply.decision,mandate_reference,action_hashandreceipt_idare echoed only when the signature holds.current_authorityis not part of validity. A revoked mandate'sAPPROVErecord staysvalid: true. It isCHECKEDonly for a caller from the record's own organisation holdingreceipts:read; otherwise{ "status": "NOT_CHECKED", "reason": "AUTHENTICATION_REQUIRED" | "NOT_PERMITTED" | "RECORD_NOT_VERIFIED" | "NO_MANDATE" }. It describes the mandate now and does not re-evaluate the action.
GET /v1/decision-intents/{id}/receipts
The full hash-chained sequence for one decision, oldest first.
GET /.well-known/mandate-keys.json · public
{ "keys": [
{ "key_id": "key_2026_10", "algorithm": "Ed25519", "public_key_pem": "-----BEGIN PUBLIC KEY-----…", "status": "ACTIVE" },
{ "key_id": "key_2026_01", "algorithm": "Ed25519", "public_key_pem": "-----BEGIN PUBLIC KEY-----…", "status": "RETIRED" }
] }
Match signature.key_id exactly; never fall back to another key. Retired
keys stay listed so the records they signed remain verifiable. Format details:
evidence-receipts.md.
AFSP authority assertions (prototype)
Off unless the deployment sets ENABLE_AFSP_ASSERTIONS=true; every route
below answers 404 otherwise. This prototypes the "External Delegated
Authority Assertion" extension mnd8t has proposed to AFSP. An approved
decision is exported as a signed statement that an AFSP receiving
institution can verify on its own: this action, in this AFSP session, is
within this exact mandate version.
GET /v1/mandates/{id}/afsp-reference · registry:read
The delegated_authority_ref a consumer signs into their AFSP authorization
scope: { provider_id, provider_keys_url, mandate_id, mandate_version, mandate_hash }. Published versions only (?version= to pick one), because
a draft can change under the same number.
POST /v1/decision-intents/{id}/afsp-assertion · decisions:write
{ "servicing_intent": {
"session_id": "sess_2e4f6a8c", "action_class": "EXECUTE",
"action_type": "FUNDING_TRANSFER", "receiving_institution": "inst_sunrise",
"payee": "supplier_acme_cloud", "amount": "500.00", "currency": "USD",
"timing": "immediate" } }
The Servicing Intent Package fields as they will appear in the package. The
assertion binds their digest, so send amount as the exact decimal string
the institution will see. mnd8t evaluates nothing again. It checks that this
is the action it already approved, and refuses otherwise:
| Check | Refused with |
|---|---|
Decision is enforce-mode and APPROVED (autonomously or by a human) |
409 |
Decision was made with an AFSP identity_attestation whose session is still valid |
409 |
session_id and receiving_institution match that attestation |
422 |
currency is the decision's asset and amount equals the approved quantity |
422 |
payee is the approved destination, or the counterparty when there is none |
422 |
A live (ISSUED or CLAIMED) authorisation artifact exists |
409 |
| No assertion exists yet for a different action on this decision | 409 |
Returns 201 { assertion, evidence_receipt_id }. A repeat with identical
fields returns 200 and the same assertion. The assertion's not_after is
the earlier of the AFSP session's and the artifact's expiry. An escalation a
human approved carries HUMAN_APPROVED in its reason_codes. The export is
recorded as a receipt in the decision's chain, with an external_assertion
block holding the assertion's hash.
Supported currencies are ISO 4217 codes, because AFSP Servicing Intent amounts are currency amounts. A stablecoin-denominated decision cannot be asserted.
Each assertion carries a status_url, inside its signed body, pointing at the
status endpoint below.
GET /v1/afsp-assertions/{id}/status · public
The live status check from section B.5 of the proposal. An assertion verifies
offline until its not_after, so an institution can call this just before
acting to learn whether the decision still stands. It needs no credentials,
because the institution holds no mnd8t key. The id comes from the
assertion's status_url, and responses are never cached.
{ "decision_id": "int_…", "action_digest": "sha256:…", "status": "ACTIVE",
"checked_at": "…", "signature": { "algorithm": "ECDSA-P256", "key_id": "…", "value": "…" } }
Signed with the same key as the assertion. status is the first that applies:
| Status | When |
|---|---|
CONSUMED |
Execution was confirmed, so the approval cannot be spent twice |
REVOKED |
The mandate was revoked, the decision cancelled, or its artifact revoked, cancelled or failed |
EXPIRED |
The assertion's not_after passed, or the decision or its artifact expired |
ACTIVE |
None of the above |
The response carries nothing else: no amounts, mandate contents or spend.
An institution should rely on the assertion only if the status verifies, names
the same decision and action digest, says ACTIVE, and is no more than 60
seconds old. verifyAfspStatus in packages/evidence applies exactly those
checks. Unknown ids return 404.
GET /.well-known/mnd8t-afsp-keys.json · public
The ECDSA P-256 verification keys, as PEM and JWK, plus the signing rule.
The signature is IEEE P1363 (r || s) in base64, over the canonical JSON of
the assertion without its signature member. That is enough to verify with
WebCrypto alone. The key is separate from the Ed25519 receipt key.
AP2 mandate input
Off unless the deployment sets ENABLE_AP2_INPUT=true. With it off, a
request carrying ap2 (live or /simulate) and the signer-key routes below
answer 404 and record nothing; requests without ap2 are unaffected.
A decision request may carry AP2 v0.2.0 mandates: a Payment Mandate and
the Checkout Mandate it pays for, each as AP2 serializes it (an SD-JWT, or
open and closed SD-JWTs joined by ~~). mnd8t verifies them, maps the closed
Payment Mandate onto the proposal and evaluates your mnd8t mandate as for any
other request. The AP2 mandates are evidence; the mnd8t mandate decides. Any
AP2 check that fails is a REJECT with a reason code and a signed receipt,
never a 422.
{
"agent_external_id": "procurement_agent_12",
"purpose": "cloud_infrastructure",
"ap2": { "version": "0.2.0", "payment_mandate": "eyJ…~…~~eyJ…~…~", "checkout_mandate": "eyJ…~…~~eyJ…~…~" }
}
- Version. Exactly
"0.2.0"(google-agentic-commerce/AP2@v0.2.0). Anything else, including v0.1.0, isAP2_UNSUPPORTED_VERSION. - Modes. Direct (human present): each mandate is one closed SD-JWT
signed by a
USERkey. Autonomous: an open mandate signed by aUSERkey (the user or a trusted agent provider) endorses the agent's key incnf.jwk, and the agent's key signs the closed mandate as a key-binding hop bound bysd_hashorissuer_jwt_hash. - Signatures. ES256 only. A chain's root is looked up by its header
kidamong the organisation's activeUSERkeys; the Checkout JWT by itskidamongMERCHANTkeys registered for the payment's payee. Nothing is fetched. Unknown key:AP2_SIGNER_UNKNOWN. A signature, hop binding or disclosure that does not verify:AP2_SIGNATURE_INVALID. - Time. Every
iatandexpagainst the decision time, with 60 seconds of skew:AP2_EXPIRED. - Binding. The Checkout Mandate's
checkout_hashis the hash of itscheckout_jwt, and the Payment Mandate'stransaction_idis that hash:AP2_CART_PAYMENT_MISMATCH. - Open mandates. Claims an open mandate pre-set must be unchanged in the
closed one, and its constraints must hold:
AP2_CONSTRAINT_NOT_MET. Evaluated:payment.amount_range,payment.allowed_payees,payment.allowed_payment_instruments,payment.allowed_pisps,payment.reference,payment.execution_date,checkout.allowed_merchants. Anything else (payment.budget,payment.agent_recurrence,checkout.line_items, or an unknown type) fails asAP2_CONSTRAINT_UNSUPPORTED, as AP2 requires. - Once. mnd8t decides once per closed Payment Mandate, keyed on the
SHA-256 of its issuer-signed JWT. A second decision is
AP2_MANDATE_REUSED; an idempotent retry still replays the first answer, and/simulatereports reuse without consuming anything. - Mapping.
payment_amountbecomesamount(already minor units),payee.idbecomescounterparty_external_id. AP2 has no payee account field, sodestinationstays yours to send; rule 10b checks it against the counterparty's registered destination as usual. Withap2, sendingamount,amount_minor,assetorcounterparty_external_idas well is422.
The response, and every receipt in the decision's chain, carries an ap2
block (absent on decisions without AP2 mandates):
"ap2": {
"protocol": "AP2",
"version": "0.2.0",
"read_as": "0.2.0",
"mode": { "payment_mandate": "AUTONOMOUS", "checkout_mandate": "AUTONOMOUS" },
"checks": [{ "check": "payment_mandate_signature", "passed": true, "code": null }, …],
"mandate_hashes": { "payment_mandate": "sha256:…", "checkout_mandate": "sha256:…" },
"references": { "payment_mandate": "…", "checkout_mandate": "…", "checkout_hash": "…" },
"signers": { "payment_mandate": "agent-provider-key-1", "checkout_mandate": "agent-provider-key-1", "checkout_jwt": "merchant-key-1" }
}
mandate_hashes are SHA-256 over each mandate exactly as sent.
references are AP2's own base64url identifiers: each closed mandate's
issuer-JWT hash (what an AP2 receipt is keyed on) and the Checkout JWT hash.
The mandates themselves are not stored. The block is part of the
Ed25519-signed receipt, so it verifies offline like the rest of it.
POST /v1/ap2-signer-keys · ap2_keys:write
Register a key AP2 mandates are verified against.
{ "role": "MERCHANT", "kid": "merchant-key-1", "jwk": { "kty": "EC", "crv": "P-256", "x": "…", "y": "…" }, "counterparty_external_id": "supplier_acme_cloud" }
role is USER (signs root mandates: a user credential or trusted agent
provider) or MERCHANT (signs Checkout JWTs; counterparty_external_id is
required and must be registered). jwk must be a public P-256 key; one with
private members is refused. An active key with the same role and kid is
409. ap2_keys:write is not in the AGENT preset.
GET /v1/ap2-signer-keys · registry:read · DELETE /v1/ap2-signer-keys/{id} · ap2_keys:write
List keys, or revoke one. Revocation applies from the next decision; the revoked key stays listed.
AP2 mapping (adapter v0.1)
Off unless the deployment sets ENABLE_AP2_MAPPING=true; the route below
answers 404 otherwise. Written against AP2 v0.2.0 (schemas at
google-agentic-commerce/AP2@e1ea56d). This is a versioned mapping. It is
not certified AP2 conformance.
An approved decision is projected as the content of an AP2 Open Payment Mandate. Your AP2 Trusted Surface or Trusted Agent Provider shows it, signs it and delegates it to the agent. mnd8t never signs an AP2 mandate: in AP2 the mandate is the user's statement, not a policy engine's.
Request the decision so the two describe one payment. Use
counterparty_external_id = the AP2 merchant id, amount.asset = the ISO 4217
currency, and external_reference = the digest of the open Checkout Mandate
(the value AP2's payment.reference constraint carries).
ap2DecisionRequest() in @mnd8t/sdk builds these fields.
POST /v1/decision-intents/{id}/ap2-open-payment-mandate · decisions:write
{ "cnf": { "jwk": { "kty": "EC", "crv": "P-256", "x": "…", "y": "…" } },
"ttl_seconds": 900 }
cnf is the RFC 7800 claim binding the mandate to the agent's key (jwk,
jwe, jku or kid). A JWK with private members is refused.
ttl_seconds defaults to 900, and the result never outlives the decision's
authorisation artifact.
{ "mandate_content": {
"vct": "mandate.payment.open.1",
"constraints": [
{ "type": "payment.reference", "conditional_transaction_id": "<checkout digest>" },
{ "type": "payment.allowed_payees", "allowed": [{ "id": "supplier_acme_cloud", "name": "Acme Cloud" }] },
{ "type": "payment.amount_range", "currency": "GBP", "min": 40000, "max": 40000 },
{ "type": "payment.execution_date", "not_after": "2026-10-04" } ],
"cnf": { … }, "payee": { … }, "payment_amount": { "amount": 40000, "currency": "GBP" },
"iat": 1791108000, "exp": 1791108900 },
"content_digest": "sha256:…",
"authorisation_artifact_id": "art_…",
"source": { "adapter": "AP2", "adapter_version": "0.1", "target": { … },
"decision_id": "int_…", "mandate_id": "mnd_…", "mandate_version": 3,
"policy_hash": "sha256:…",
"authority_record": { "receipt_id": "evd_…", "action_hash": "sha256:…" } } }
Every claim is the approved decision or narrower. Payee and amount are
pre-set, so AP2 requires the closed Payment Mandate to carry exactly those
values. There is no payment.agent_recurrence, so the mandate is single use.
source is not an AP2 claim; it tells the signer what it is signing and why.
Projecting spends the approval. A signed AP2 mandate is a way to pay
that never passes your protected executor. So the projection claims the
decision's authorisation artifact, exactly as an executor would, with
executor_reference = ap2:<content_digest>. It also signs a receipt into
the decision's chain recording that claim; source.authority_record names
that receipt. One approval yields one AP2 mandate. A second projection, or
an executor claim afterwards, is refused. Report the AP2 payment's outcome
as for any executor: /confirm or /fail with the returned
authorisation_artifact_id.
The projection evaluates nothing, and it refuses anything it could not express without widening:
| Check | Refused with |
|---|---|
Decision is enforce-mode and APPROVED (autonomously or by a human) |
409 |
Its authorisation artifact is still ISSUED (unclaimed, unexpired) |
409 |
Action type is PAYMENT; asset is an ISO 4217 currency |
409 |
The decision names a counterparty and carries external_reference |
409 |
cnf is a public key binding |
422 |
Checking an AP2 payment against the record. A credential provider,
merchant or executor that holds the open and closed AP2 mandates and the
decision's Authority Record runs AP2's own checks first, then
verifyAp2PaymentAgainstRecord() from @mnd8t/sdk (0.3.0 and later). That
function checks five things:
- the record's signature and action hash;
- an enforce-mode decision that authorised the payment and was not revoked;
- payee, currency and amount, field by field;
- the checkout reference;
- that the payment is immediate.
It answers what was decided when the record was signed. Whether the
authority still stands is current_authority on
POST /v1/evidence-receipts/verify, reported to the record's own
organisation.
Authentication and account
| Endpoint | Notes |
|---|---|
GET /v1/auth/methods |
Which sign-in methods are enabled: { providers: ["google","github"], password_fallback } |
GET /v1/auth/oauth/{provider} |
Starts OAuth (google | github); 302 to the provider |
GET /v1/auth/oauth/{provider}/callback |
Completes sign-in; creates the account and organisation on first use |
POST /v1/auth/signup · POST /v1/auth/login |
Password auth. Disabled in production unless AUTH_PASSWORD_ENABLED=true → 403 |
New accounts, by password or OAuth, are created only for addresses on
SIGNUP_ALLOWLIST (exact emails and @domain entries; * for anyone). When
it is unset, sign-up is closed in production and open elsewhere. A refused
password sign-up returns 403; a refused OAuth sign-up redirects to
/login?error=signup_closed. Existing accounts always sign in.
| POST /v1/auth/logout · GET /v1/auth/me | Session endpoints |
Developers
| Endpoint | Role | Notes |
|---|---|---|
POST /v1/api-keys |
ADMIN | { name, environment }. Secret returned once. |
GET /v1/api-keys |
ADMIN | Metadata only — never the secret. Includes last_used_at, when the key last authenticated a request (to the minute; null if never), and last_user_agent, that request's User-Agent (mnd8t-sdk/<version> from the SDK) |
DELETE /v1/api-keys/{id} |
ADMIN | Immediate; subsequent calls get 401 |
POST /v1/webhook-endpoints |
ADMIN | { url }. Signing secret returned once. |
GET /v1/webhook-endpoints · DELETE /v1/webhook-endpoints/{id} |
ADMIN | List / deactivate |
POST /v1/webhook-endpoints/{id}/test |
ADMIN | Queues a test event |
GET /v1/webhook-deliveries |
ADMIN | History; filter by status |
POST /v1/webhook-deliveries/{id}/retry |
ADMIN | Requeue a failed delivery |
Webhook events
decision.approved · decision.rejected · decision.escalated ·
decision.expired · approval.approved · approval.rejected ·
execution.confirmed · execution.failed · mandate.published ·
mandate.revoked
Headers: mandate-signature: t=<unix>,v1=<hmac-sha256-hex>,
mandate-event-id, mandate-event-type.
Verify HMAC-SHA256 over "<t>.<raw body>" with your endpoint secret; reject
if |now − t| > 300s. At-least-once delivery with exponential backoff (8
attempts, 30s → 6h) — deduplicate on mandate-event-id. Verification code:
developer guide §9.
System
| Endpoint | Notes |
|---|---|
GET /livez |
Liveness. Use this on Cloud Run — Google's frontend intercepts /healthz on run.app domains. |
GET /healthz |
Liveness (direct hosts) |
GET /readyz |
Readiness; checks the database. 503 when down. |
GET /v1/metrics |
Decision counts, approval stats, daily series, funnel |
POST /v1/product-events |
Records quickstart_copied | design_partner_flagged |
Reason codes
Returned in reason_codes on every decision — all triggered codes, not just
the first.
Hard failures → REJECT
AGENT_INACTIVE · MANDATE_NOT_FOUND · MANDATE_NOT_PUBLISHED ·
MANDATE_NOT_EFFECTIVE · MANDATE_EXPIRED · MANDATE_REVOKED ·
OUTSIDE_PERMITTED_SCHEDULE · ASSET_NOT_ALLOWED · COUNTERPARTY_BLOCKED ·
DESTINATION_NOT_AUTHORISED ·
PURPOSE_NOT_ALLOWED · ABSOLUTE_LIMIT_EXCEEDED · DAILY_BUDGET_EXCEEDED ·
MONTHLY_BUDGET_EXCEEDED · ASSET_ABSOLUTE_LIMIT_EXCEEDED ·
ASSET_DAILY_BUDGET_EXCEEDED · ASSET_MONTHLY_BUDGET_EXCEEDED ·
VALUATION_NOT_ASSERTED · IDENTITY_ATTESTATION_MISSING ·
IDENTITY_ATTESTATION_EXPIRED · IDENTITY_ATTESTATION_UNTRUSTED ·
IDENTITY_ATTESTATION_AGENT_MISMATCH
Approval triggers → ESCALATE
COUNTERPARTY_NOT_APPROVED · DESTINATION_UNVERIFIED · HUMAN_APPROVAL_REQUIRED
Informational
APPROVED_WITHIN_MANDATE · SHADOW_ONLY
Precedence: any hard failure → REJECT; otherwise any approval trigger →
one ESCALATE carrying every triggered reason; otherwise APPROVE. Full
evaluation order: policy-model.md.