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_minor is in the mandate's currency, already converted. mnd8t sums it exactly as sent; it records currency but does not convert it or reject a mismatch. Issuing providers may report a capture as a negative amount — submit the absolute value.
  • Put quantity on 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 latest observed_at among 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 whose counterparty_external_id differs fails the outcome with EVIDENCE_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_at is the source's event time, not the time your handler ran.
  • payload is 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's action_hash is the digest of the action it records. null for a 1.0 receipt, which has none.
  • action_match: your expected_action hashes to the record's action_hash. null when you sent none.
  • valid: all of the above that apply.
  • decision, mandate_reference, action_hash and receipt_id are echoed only when the signature holds.
  • current_authority is not part of validity. A revoked mandate's APPROVE record stays valid: true. It is CHECKED only for a caller from the record's own organisation holding receipts: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, is AP2_UNSUPPORTED_VERSION.
  • Modes. Direct (human present): each mandate is one closed SD-JWT signed by a USER key. Autonomous: an open mandate signed by a USER key (the user or a trusted agent provider) endorses the agent's key in cnf.jwk, and the agent's key signs the closed mandate as a key-binding hop bound by sd_hash or issuer_jwt_hash.
  • Signatures. ES256 only. A chain's root is looked up by its header kid among the organisation's active USER keys; the Checkout JWT by its kid among MERCHANT keys 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 iat and exp against the decision time, with 60 seconds of skew: AP2_EXPIRED.
  • Binding. The Checkout Mandate's checkout_hash is the hash of its checkout_jwt, and the Payment Mandate's transaction_id is 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 as AP2_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 /simulate reports reuse without consuming anything.
  • Mapping. payment_amount becomes amount (already minor units), payee.id becomes counterparty_external_id. AP2 has no payee account field, so destination stays yours to send; rule 10b checks it against the counterparty's registered destination as usual. With ap2, sending amount, amount_minor, asset or counterparty_external_id as well is 422.

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.

30 min read · Need something that isn't here? Browse all documentation