Developer guide
How to put mnd8t between your AI agent and your money, end to end.
mnd8t decides and proves. You — or your regulated provider — execute. mnd8t never holds your keys, never routes an order, and never moves money. Understanding that split is most of understanding the API.
Contents
- The mental model
- Authentication
- Set up: agent, counterparties, mandate
- The decision call
- Handling each outcome
- Executing with an authorisation artifact
- Reporting the outcome
- Idempotency and retries
- Webhooks
- Evidence receipts
- Shadow mode
- Errors
- Going to production
1. The mental model
Five nouns. Learn these and the API is obvious.
| Noun | What it is |
|---|---|
| Agent | The software actor. Has an external_id you choose — you pass it on every decision. |
| Mandate | The delegated authority: limits, budgets, counterparties, purposes, hours. Versioned; published versions are immutable. |
| Counterparty | Who may be paid. Carries a customer-asserted authority_status, not a compliance verdict. |
| Decision intent | One proposed action, submitted before execution. Gets APPROVE, REJECT, or ESCALATE. |
| Authorisation artifact | A signed, single-use, short-lived token issued for an approved action. Your executor claims it, then executes. |
The lifecycle:
your agent
│ 1. proposed action
▼
POST /v1/decision-intents ──────► APPROVE ──► artifact issued
│ ESCALATE ─► human approves ──► artifact issued
│ REJECT ───► stop. do not execute.
▼
2. GET the artifact, verify its signature locally
3. POST …/claim (atomic, exactly one executor wins)
4. execute with YOUR credentials, through YOUR provider
5. POST …/confirm (report the outcome back)
▼
signed evidence receipts at every step, hash-chained
Two invariants worth internalising:
- No LLM is in the decision path. The engine is deterministic: same inputs, same mandate version, same state → same answer, every time.
- Money is always integer minor units.
50000is £500.00. There are no floats anywhere near an amount.
2. Authentication
Two credentials, for two different callers.
API keys — for your agent and backend. Authorization: Bearer mdt_test_….
Created in the dashboard (Developers) or via POST /v1/api-keys. Shown once;
stored only as a SHA-256 hash. Scoped to its own organisation and environment,
and to a specific set of scopes.
Two presets. AGENT — the default — grants decisions:*, artifacts:*,
receipts:read and registry:read: everything needed to propose actions,
claim artifacts, confirm execution and read evidence. ADMIN adds the
authority to change what an agent may do: create counterparties and agents,
draft mandates, publish them, manage webhooks and mint keys.
An agent should carry an AGENT key. Give it an ADMIN one and the thing
you are constraining can add a counterparty and publish a mandate permitting
the payment it wants — the control model becomes circular. A key also cannot
mint a key wider than itself, so keys:write is not a route back.
export MANDATE_API_KEY=mdt_test_...
export MANDATE_API_URL=https://your-api-host
Session cookies — for humans in the dashboard. Sign in with Google or
GitHub. Roles: ADMIN (manage), APPROVER (act on approvals), VIEWER
(read-only), enforced server-side per route.
Environments are separate: SANDBOX and LIVE_TEST keys never see each
other's data.
3. Set up: agent, counterparties, mandate
import { MandateClient } from "@mnd8t/sdk";
const mandate = new MandateClient({
apiKey: process.env.MANDATE_API_KEY!,
baseUrl: process.env.MANDATE_API_URL,
});
const agent = await mandate.agents.create({
name: "Procurement Agent",
external_id: "procurement_agent_12", // you will pass this on every decision
purpose: "Purchase approved software and cloud services",
principal_type: "ORGANISATION",
principal_reference: "acme_ltd", // who is delegating
});
await mandate.counterparties.create({
external_id: "supplier_acme_cloud",
display_name: "Acme Cloud",
authority_status: "CUSTOMER_APPROVED", // your assertion, not a screening result
destination: "0x1111111111111111111111111111111111111111",
});
Then the mandate. Every field is optional except the limits and window — but the more you express, the more mnd8t can catch:
const m = await mandate.mandates.create({
agent_id: agent.id,
name: "Cloud procurement mandate",
mode: "SHADOW", // start here. always.
policy: {
currency: "GBP",
allowed_assets: ["USDC"],
allowed_counterparties: ["supplier_acme_cloud"],
per_transaction: {
autonomous_limit: 75_000, // above this → escalate to a human
absolute_limit: 250_000, // above this → reject outright
},
budgets: [
{ period: "DAY", limit: 150_000 },
{ period: "MONTH", limit: 500_000 },
],
approval: {
new_counterparty_requires_approval: true,
reservation_on_escalation: true, // hold the budget while a human decides
expires_after_minutes: 60,
},
allowed_purposes: ["software", "cloud_infrastructure"],
schedule: {
timezone: "Europe/London",
days: ["MON", "TUE", "WED", "THU", "FRI"],
start: "08:00",
end: "18:00",
},
effective_at: new Date().toISOString(),
expires_at: new Date(Date.now() + 30 * 86_400_000).toISOString(),
},
});
Publishing requires a delegator attestation — whoever publishes asserts they hold the authority they are delegating. mnd8t records the assertion in the evidence chain; it does not verify that the authority legally exists.
await mandate.mandates.publish(m.id, {
delegation: {
delegatorRole: "Finance Director",
authoritySource: "CORPORATE_POLICY",
sourceReference: "DoA-2026-v4-section-8",
},
changeNote: "Initial cloud procurement authority",
});
Published versions are frozen. Editing the policy creates a new draft version you publish separately — and every decision records exactly which version and policy hash it was judged against.
Full field semantics: policy-model.md.
4. The decision call
The one call that matters. Make it before you touch a wallet or payment provider, never after.
const decision = await mandate.decisions.authorize({
agent_external_id: "procurement_agent_12",
external_reference: "INV-2026-0042", // your invoice/order id
action_type: "PAYMENT", // PAYMENT | TRANSFER only
amount: { asset: "USDC", asset_amount_minor: 84_000_000 },
valuation: {
policy_amount_minor: 50_000, // £500.00, in the mandate's currency
policy_currency: "GBP",
source: "CUSTOMER_SUPPLIED",
rate_reference: "provider_quote_123",
valued_at: new Date().toISOString(),
},
counterparty_external_id: "supplier_acme_cloud",
destination: "0x1111111111111111111111111111111111111111",
purpose: "cloud_infrastructure",
});
Three things people get wrong:
- You supply the valuation, and mnd8t trusts it. mnd8t never fetches an
exchange rate — doing so would make it a valuation party. Send
policy_amount_minoralready converted into the mandate's currency, with provenance. The engine evaluates only that normalised figure. action_typeis a closed set:PAYMENTandTRANSFER. Anything trade-shaped — swap, stake, lend, invest — returns422 UNSUPPORTED_ACTION_TYPE. That is deliberate scope, not a missing feature.- The flat form still works (
amount_minor,policy_amount_minor,policy_currency,asset) and is accepted for compatibility, but it is deprecated. Preferamount+valuation.
5. Handling each outcome
switch (decision.effective_decision) {
case "APPROVE":
// decision.authorisation_artifact is present → go to §6
break;
case "ESCALATE":
// A human must decide. Send them decision.approval.approval_url —
// a single-use, expiring link that works without an account.
// No artifact exists yet; one is issued if they approve.
break;
case "REJECT":
// Do not execute. decision.reason_codes says why.
break;
case "OBSERVE":
// Shadow mode. Nothing was blocked or reserved.
// decision.would_decide is what enforcement would have done.
break;
}
reason_codes is always populated, for every outcome, with all triggered
codes — not just the first. An escalation caused by both a new counterparty
and an over-limit amount returns both. GET /v1/decision-intents/{id} adds a
rule-by-rule trace if you want to show a human exactly what happened.
6. Executing with an authorisation artifact
An approved decision yields an artifact: signed, bound to this exact action, expiring, and claimable once. This is the part that keeps execution on your side of the line.
const artifact = decision.authorisation_artifact!;
// 1. Fetch it and verify the signature yourself. Do not skip this —
// it is the whole point. Verification is local and offline-capable.
const envelope = await mandate.artifacts.get(artifact.id);
const check = await mandate.artifacts.verify(envelope.artifact);
if (!check.valid) throw new Error(`refusing to execute: ${check.reason}`);
// 2. Claim it. Atomic: if two executors race, exactly one wins and the
// other gets 409. Claim before you execute, never after.
await mandate.artifacts.claim(artifact.id, "executor-instance-1");
// 3. Execute — your provider, your credentials, which mnd8t never sees.
const txRef = await myWallet.pay({
to: envelope.artifact.destination,
amountMinor: envelope.artifact.amount_minor,
asset: envelope.artifact.asset,
});
Act only on what the artifact says. Its signature covers the full execution context: organisation, agent, mandate id and version, the decision intent, the financial account, action type, asset and asset amount, the policy valuation, destination, counterparty, nonce, and the issued/not-before/expires window. If any of those differ from what you were about to do, something is wrong and you should stop.
If the provider rejects the payment:
await mandate.artifacts.fail(artifact.id, "PROVIDER_REJECTED");
// The decision stays APPROVED and its budget reservation stays active,
// so you can retry with a fresh artifact:
const fresh = await mandate.artifacts.reissue(decision.id);
// Reissue re-checks live mandate state — a revoked or expired mandate refuses.
Only cancel the decision when you are abandoning the action entirely:
await mandate.decisions.cancel(decision.id, "provider rejected the payment");
Cancelling releases the budget reservation and is terminal — reissue then
returns 409; to try again after a cancel, submit a new decision intent.
A worked reference executor is yours to download and start from: reference-executor.ts. It runs against a simulated wallet with no credentials at all, so you can watch the whole loop before wiring your own provider.
7. Reporting the outcome
Confirmation is how mnd8t learns what actually happened. It does not mean mnd8t executed anything.
await mandate.decisions.confirm(decision.id, {
provider: "MY_PROVIDER",
external_reference: txRef,
authorisation_artifact_id: artifact.id, // required in enforce mode
reported_by: "CUSTOMER_EXECUTOR",
verification: { method: "PROVIDER_LOOKUP", verified: true },
});
Be honest in verification. CUSTOMER_ASSERTED means "we say it happened";
PROVIDER_LOOKUP or NETWORK_LOOKUP mean you checked. The receipt records
which, and an auditor can tell the difference.
Confirming moves the budget reservation from reserved to consumed. Duplicate confirmations with the same reference are idempotent — safe to retry.
8. Idempotency and retries
Every write accepts Idempotency-Key; the decision endpoint requires one
(minimum 8 characters). The SDK generates one per call automatically, which
is also what makes its retries safe.
- Same key, same payload → the original response is replayed. No second decision, no double reservation.
- Same key, different payload →
409 IDEMPOTENCY_CONFLICT. - Records are retained 7 days.
Derive keys from something stable in your domain — an invoice id, a job id — so a retry after a network timeout naturally collides:
await mandate.decisions.authorize({
...payload,
idempotencyKey: `invoice-${invoice.id}-attempt-1`,
});
Retry 5xx and network failures freely. Do not retry 4xx without changing
something.
9. Webhooks
Register an endpoint (Developers page or POST /v1/webhook-endpoints) and
store the signing secret — shown once.
Events: decision.approved, decision.rejected, decision.escalated,
decision.expired, approval.approved, approval.rejected,
execution.confirmed, execution.failed, mandate.published,
mandate.revoked.
Verify every delivery:
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=", 2)));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false; // replay window
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}
Headers: mandate-signature: t=…,v1=…, mandate-event-id,
mandate-event-type. Delivery is at-least-once with exponential backoff
(8 attempts, 30s → 6h) — deduplicate on mandate-event-id. Failed deliveries
are visible and manually retryable in the dashboard.
Use the raw request body for verification, not a re-serialised object.
10. Evidence receipts
Every state change — decision, approval, artifact issuance, execution, cancellation, revocation — writes a new immutable receipt, hash-chained to the previous one. Receipts are the product's proof layer, and they are verifiable without trusting mnd8t's database or its API:
const receipt = await mandate.receipts.get(receiptId);
const { valid } = await mandate.receipts.verify(receipt.receipt); // local Ed25519
Verification needs only the receipt and the matching public key from
/.well-known/mandate-keys.json — match on signature.key_id. It works
offline and in any runtime with WebCrypto, so an auditor can check a receipt
you email them without any access to your account or ours.
Every receipt also carries mandate_service_role, recording what mnd8t did
and did not do. transaction_executed is structurally false, because there
is no execution code path in the service.
Format and canonicalisation: evidence-receipts.md.
11. Shadow mode
Start here. A SHADOW mandate evaluates everything and blocks nothing:
// Replay yesterday's real payments through a shadow mandate.
for (const tx of history) {
const d = await mandate.decisions.authorize({ ...toIntent(tx) });
console.log(tx.ref, d.would_decide, d.reason_codes.join(", "));
}
Responses come back effective_decision: "OBSERVE" with would_decide
telling you what enforcement would have done, plus a SHADOW_ONLY reason
code. No budget is consumed, no approval is created, no artifact is issued,
nothing is blocked.
Run it against real traffic for a week. You will find policy gaps — a
supplier you forgot to approve, a purpose nobody declared — and you will find
them without an incident. Switch the mandate to ENFORCE when the shadow
results are boring.
12. Errors
{ "error": { "code": "VALIDATION_FAILED", "message": "…", "details": null } }
| Status | Code | Usually means |
|---|---|---|
| 401 | UNAUTHENTICATED |
Missing, malformed, or revoked API key |
| 403 | FORBIDDEN |
Role lacks permission for this route |
| 404 | NOT_FOUND |
Unknown id — or it belongs to another organisation |
| 409 | IDEMPOTENCY_CONFLICT |
Key reused with a different payload |
| 409 | CONFLICT |
State machine says no: artifact already claimed, approval already used, mandate already revoked |
| 422 | VALIDATION_FAILED |
Schema violation; details carries the field errors |
| 422 | UNSUPPORTED_ACTION_TYPE |
Not PAYMENT or TRANSFER |
| 429 | RATE_LIMITED |
Back off and retry |
| 500 | INTERNAL |
Ours. Safe to retry with the same idempotency key. |
The SDK throws typed subclasses — MandateAuthenticationError,
MandateValidationError, MandateIdempotencyConflictError,
MandateRateLimitError, MandateNotFoundError — so you can branch on the
class rather than parsing strings.
A cross-tenant id returns 404, never 403: mnd8t will not confirm that
another organisation's resource exists.
13. Going to production
Before enforcement
- Ran in shadow long enough to be boring
- Every counterparty the agent legitimately pays is
CUSTOMER_APPROVED - Every purpose the agent legitimately uses is in
allowed_purposes - Approvers know they will receive links, and know what to do with them
-
expires_after_minutesmatches how fast your approvers actually respond - Webhook endpoint verifies signatures and deduplicates on event id
Before you trust the evidence
- Your executor verifies artifact signatures and refuses on failure
- You have verified a receipt end-to-end at least once, independently
-
verification.methodreflects reality — do not claimPROVIDER_LOOKUPfor an unchecked assertion
Operationally
- Confirm or cancel promptly: approved reservations expire after 24h
- Know the kill switches — revoke a mandate (
POST /v1/mandates/{id}/revoke) to stop one authority immediately, or suspend the agent (PATCH /v1/agents/{id}→SUSPENDED) to stop it everywhere - Live-money execution stays disabled — the service refuses to start otherwise. See security for the review that gates it
Next: api.md for the endpoint reference ·
policy-model.md for policy semantics ·
evidence-receipts.md for the proof layer ·
/docs on your API host for interactive OpenAPI.