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

  1. The mental model
  2. Authentication
  3. Set up: agent, counterparties, mandate
  4. The decision call
  5. Handling each outcome
  6. Executing with an authorisation artifact
  7. Reporting the outcome
  8. Idempotency and retries
  9. Webhooks
  10. Evidence receipts
  11. Shadow mode
  12. Errors
  13. 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. 50000 is £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:

  1. 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_minor already converted into the mandate's currency, with provenance. The engine evaluates only that normalised figure.
  2. action_type is a closed set: PAYMENT and TRANSFER. Anything trade-shaped — swap, stake, lend, invest — returns 422 UNSUPPORTED_ACTION_TYPE. That is deliberate scope, not a missing feature.
  3. The flat form still works (amount_minor, policy_amount_minor, policy_currency, asset) and is accepted for compatibility, but it is deprecated. Prefer amount + 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_minutes matches 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.method reflects reality — do not claim PROVIDER_LOOKUP for 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.

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