Authorization Engine
How POST /api/v1/authorize decides: the pipeline, the request and response fields, reason codes, and the step-up flow.
Overview
Every call to POST /api/v1/authorize runs the Decision Trust Protocol pipeline: seven decision gates followed by two system steps. It returns one of three decisions: APPROVE, DECLINE, or STEP_UP (a human must confirm before the transaction proceeds). Every decision carries machine-readable reason codes, the agent's KYA Score, and a structured risk assessment, and is written to the audit log. The exceptions are an AGENT_NOT_FOUND decline, which has no agent to attach a record to, and the fail-policy answer returned while the engine itself is unavailable.
Engine decision time is 23ms under burst, with a sustained end-to-end P50 under 100ms.
The authorization pipeline
Steps 1 to 7 run in order and any of them can end the evaluation early with a DECLINE or STEP_UP. Steps 8 and 9 are system steps that record the decision and run follow-up work.
Agent resolve
Finds the agent by agent_id or agent_did within your tenant. An agent that does not exist and an agent that belongs to another tenant both get the same AGENT_NOT_FOUND decline. Suspended or revoked agents are declined, and if the request names a capability, the agent must hold it.
Intent verification
Validates the mandatory intent_context. If the request presents a Verifiable Intent chain in vi_chain, the chain is verified in full, and an invalid chain is a hard decline (see Verifiable intent).
Anomaly gate (EDQS)
Compares the stated intent with the agent's behavioral baseline for the session. Scoring starts once there is a baseline: 3 earlier transactions in the same session when intent_context.session_id is sent, otherwise 5 earlier transactions for the agent. With platform defaults, an anomaly score of 0.20 or more steps up and 0.70 or more declines.
Mandate enforcement
Checks the agent's active mandate: per-transaction amount, currency, MCC allow and block lists, merchant allowlist, countries, allowed hours and days, and daily and monthly amount and count caps. If the mandate constrains a field that the request leaves out, the transaction is declined and reason_codes include MANDATE_SCOPE_UNVERIFIABLE.
Behavioral overlay
Applies the agent's trust-zone multiplier (cognitive_limit_multiplier) to the mandate's per-transaction amount limit: 1.0 in GREEN, 0.75 in AMBER, 0.50 in RED, and 0.25 in CRITICAL. See KYA scoring.
Risk scoring
Scores velocity spikes, amount outliers, rapid-fire bursts, and behavioral, geographic and temporal indicators into risk_score and risk_assessment. With platform defaults, a risk score of 0.85 or more (or any critical signal) declines, and 0.60 or more steps up.
KYA decision
Computes the agent's KYA Score for this transaction. Below 0.15 the transaction is declined and below 0.40 it steps up (platform defaults, adjustable through the Thresholds API). Otherwise it is approved.
Persist and webhooks (system)
Writes the signed decision record to the audit log and fires the matching webhooks (authorization.decline, step_up.created, gate.fired, and trust-zone events).
Async post-decision (system)
Runs after the response is returned: trust promotion checks, deeper risk analysis, and delivery of step-up challenges. None of this delays the decision.
Request format
The POST /api/v1/authorize body accepts these fields:
| Field | Type | Required | Description |
|---|---|---|---|
agent_id | string | One of the two | Agent id (agt_…). |
agent_did | string | One of the two | The agent's W3C DID, as an alternative to agent_id. |
amount | decimal | Yes | Greater than 0. Send a decimal string such as "42.99"; numbers are also accepted. |
currency | string | No | ISO 4217 code. Defaults to USD and must match the mandate currency. |
intent_context | object | Yes | Why the agent is transacting. See the table below. |
mcc | string | If constrained | 4-digit Merchant Category Code. Required in practice when the mandate restricts MCCs. |
merchant_name | string | No | Merchant display name (up to 255 characters). |
merchant_id | string | If constrained | Merchant identifier (up to 128 characters). Required when the mandate has a merchant allowlist. |
country | string | If constrained | ISO 3166-1 alpha-3 code, for example "USA". Two-letter codes are rejected. Required when the mandate restricts countries. |
capability | string | No | A capability the agent must hold for this transaction, for example PURCHASE. |
rail | string | No | CARD (default) or CRYPTO. CRYPTO requires the crypto block; see Rails. |
crypto | object | With CRYPTO | The on-chain transfer: chain, asset, counterparty, from_address, value, operation. For USDC, value may not exceed amount and currency must be USD (else 422). Not allowed on a card request. |
vi_chain | object | No | Verifiable Intent chain: l1, l2, and optionally l3a and l3b. |
idempotency_key | string | No | 8 to 128 characters (letters, digits, _ - : .). A retry with the same key returns the original decision instead of creating a second authorization. |
client_reference | string | No | Your own order or intent reference (up to 128 characters), stored as sent. |
transaction_id | string | No | An existing txn_ id to thread a retry or re-authorization onto. Omit it and the engine mints a new one. |
network_rrn, network_stan, auth_code, network_reference | string | No | Card network references, if you already hold them. |
tx_hash, chain_id | string, integer | No | On-chain references, if already known. |
runtime_evidence | object | No | What the agent did before asking: controls performed, tools called, trace hash. Recorded on the decision record; it does not change the decision in this version. |
metadata | object | No | Free-form context stored in the audit log. |
intent_context
| Field | Type | Required | Description |
|---|---|---|---|
intent_type | string | No | One of PURCHASE (default), REPEAT_PURCHASE, RECURRING, COF, REFUND, TRANSFER. Each type is scored on different criteria. |
task_reference | string | Yes | The task or goal this transaction serves (up to 500 characters). |
reasoning_summary | string | No | The agent's reasoning (up to 2,000 characters). Expected for PURCHASE, REPEAT_PURCHASE and TRANSFER. |
confidence | number | No | Self-reported confidence, 0 to 1. |
alternatives_considered | integer | No | How many options the agent evaluated before choosing this one. |
interaction_depth | integer | No | How many tool calls or steps the agent is into its current task. |
session_id | string | No | Your orchestration session id (up to 128 characters). Groups transactions for session-level scoring. |
recurrence_number | integer | No | For RECURRING and COF: which occurrence this is. |
original_authorization_id | string | No | For REFUND: the original authorization. For COF: the authorization that stored the credential. |
If the mandate restricts MCCs, merchants or countries and the request leaves that field out, the transaction is declined and reason_codes include MANDATE_SCOPE_UNVERIFIABLE. The API ignores fields it does not recognize, so a misspelled field (for example merchant instead of merchant_name, or session_id at the top level instead of inside intent_context) is dropped without an error.
Response format
A successful call returns 200 with the decision and every signal that contributed to it:
{
"authorization_id": "auth_1782561623760_13a96cd71daa0fc5",
"transaction_id": "txn_1782561623760_4be1f07a2c9d8e11",
"correlation_key": "5d0c9b3e…",
"decision": "APPROVE",
"reason_codes": [],
"reason_detail": "All checks passed",
"agent_id": "agt_…",
"mandate_id": "mnd_…",
"kya_score": 0.87,
"trust_level": "VERIFIED",
"risk_score": 0.12,
"risk_assessment": {
"score": 0.12,
"reason_codes": [],
"condition_code": "00",
"velocity_indicator": false,
"behavioral_indicator": false,
"geographic_indicator": false,
"temporal_indicator": false,
"amount_indicator": false,
"active_signal_count": 0,
"critical_signal_count": 0,
"highest_severity": "NONE",
"recommendation": "APPROVE",
"baseline_transactions": 214,
"baseline_age_hours": 1630.5
},
"daily_amount_used": "128.40",
"daily_count_used": 3,
"monthly_amount_used": "1840.10",
"monthly_count_used": 41,
"intent_context_provided": true,
"intent_anomaly_score": 0.05,
"intent_anomaly_triggered": false,
"intent_anomaly_skipped": false,
"vi_verified": false,
"vi_mode": null,
"cognitive_limit_active": false,
"cognitive_limit_multiplier": 1.0,
"session_terminate_recommended": false,
"attestation": null,
"processing_time_ms": 8.4
}
| Field | Description |
|---|---|
authorization_id | Id of this decision in the audit log (auth_…). |
transaction_id | Rail-agnostic transaction id (txn_…). Reversals, retries and re-authorizations of the same transaction share it. |
correlation_key | Groups structurally similar transactions (same agent, rail, counterparty, MCC and amount band). |
decision | APPROVE, DECLINE or STEP_UP. |
reason_codes, reason_detail | Machine-readable reasons, most specific first, and a human-readable explanation. See Reason codes. |
agent_id, mandate_id | The resolved agent and the mandate that was evaluated. |
kya_score, trust_level | The agent's KYA Score (0 to 1) and trust level at decision time. |
risk_score, risk_assessment | The agent's risk score (0 to 1) and the assessment behind it: up to four reason codes, a condition code (00 to 07), indicator flags and the risk engine's recommendation. The assessment is shaped for DE 48.75 mapping; it is not a scheme-certified score. |
daily_amount_used, daily_count_used, monthly_amount_used, monthly_count_used | Velocity already consumed before this transaction. Amounts are decimal strings. |
intent_context_provided | Whether the request carried intent reasoning. The task_reference counts, so this is true for every valid request. |
intent_anomaly_score, intent_anomaly_triggered, intent_anomaly_skipped | Anomaly gate output. intent_anomaly_skipped is true when the gate did not score, for example before the session has enough history. |
vi_verified, vi_mode | Whether a presented Verifiable Intent chain verified, and its mode (immediate or autonomous). |
cognitive_limit_active, cognitive_limit_multiplier | Whether the trust-zone multiplier is tightening this agent's limits, and its current value. |
session_terminate_recommended | True when the agent is in the CRITICAL zone. An advisory signal for your orchestration layer; it does not block the current transaction. |
attestation | Crypto approvals only: the signed transaction-authorization attestation {jws, kid, nonce, valid_after, valid_before, expires_at}. See Rails. |
processing_time_ms | Engine time for this decision, in milliseconds. |
Decisions
APPROVE
Every gate passed. Proceed with the payment. On the crypto rail the response also carries a signed attestation that the wallet verifies before signing.
DECLINE
Do not proceed. reason_codes says why, most specific first, and reason_detail explains it in words. Common causes: a mandate limit or scope violation, a high risk score, a KYA Score below the floor, or an intent anomaly. Declines also fire authorization.decline, except when the agent is not found or the engine is unavailable.
STEP_UP
A human must confirm before the transaction proceeds. Mandate Labs opens a challenge that is valid for 5 minutes and fires step_up.created. See the step-up flow below.
Step-up flow
How the challenge reaches the human depends on the Principal's step-up method, set with the step_up block at onboarding or with PUT /api/v1/principals/me/step-up:
| Method | What happens |
|---|---|
MANDATE_DASHBOARD (default) | No message is sent. The challenge appears in GET /api/v1/authorize/step-up/pending and in the dashboard; your system collects the answer and calls confirm or deny. |
MANDATE_EMAIL | The principal receives a one-time code by email and, where email action links are enabled, a single-use approve or deny link. Submit the code with verify-otp. |
MANDATE_SMS | The principal receives a one-time code by SMS. Submit it with verify-otp. |
ISSUER_3DS | Your issuer flow runs the challenge (for example 3-D Secure) and reports the result with confirm or deny. |
# The principal approved curl -X POST https://api.mandatelabs.ai/api/v1/authorize/step-up/auth_…/confirm \ -H "X-API-Key: mdt_live_…" # → { "success": true, "authorization_id": "auth_…", "confirmed_by": "[email protected]", "confirmed_at": "…" } # The principal declined (reason is optional) curl -X POST https://api.mandatelabs.ai/api/v1/authorize/step-up/auth_…/deny \ -H "X-API-Key: mdt_live_…" -H "Content-Type: application/json" \ -d '{ "reason": "Principal did not recognize the purchase" }' # Check a one-time code delivered by email or SMS curl -X POST https://api.mandatelabs.ai/api/v1/authorize/step-up/auth_…/verify-otp \ -H "X-API-Key: mdt_live_…" -H "Content-Type: application/json" \ -d '{ "code": "482913" }' # Challenges still waiting for an answer (paginated) curl https://api.mandatelabs.ai/api/v1/authorize/step-up/pending \ -H "X-API-Key: mdt_live_…"
There is no step_up_confirmed flag on /authorize: a resubmitted request is evaluated as a new authorization. Confirm or deny the challenge against its authorization_id instead. A denial changes that authorization's decision to DECLINE in the audit log, and after 5 minutes a challenge can no longer be confirmed.
Reason codes
reason_codes lists the reasons for a DECLINE or STEP_UP, most specific first. Codes for limits tightened by the agent's trust zone are listed on the KYA scoring page.
| Code | Typical decision | Meaning |
|---|---|---|
AGENT_NOT_FOUND | DECLINE | No agent with that id or DID in your tenant. Also returned for agents that belong to another tenant. |
AGENT_NOT_ACTIVE, AGENT_SUSPENDED, AGENT_REVOKED | DECLINE | The agent is not in ACTIVE status. |
CAPABILITY_NOT_ALLOWED | DECLINE | The agent does not hold the capability named in the request. |
VI_VERIFICATION_FAILED, VI_CONSTRAINT_VIOLATION, VI_AGENT_KEY_MISMATCH | DECLINE | A presented Verifiable Intent chain failed verification. See Verifiable intent. |
INTENT_ANOMALY_STEP_UP | STEP_UP | The anomaly gate scored the intent between the step-up and decline thresholds. |
INTENT_ANOMALY_DETECTED | DECLINE | The anomaly gate scored the intent at or above the decline threshold. |
NO_ACTIVE_MANDATE, MANDATE_EXPIRED | DECLINE | The agent has no active, unexpired mandate that covers the request's rail. |
AMOUNT_EXCEEDS_PER_TXN | DECLINE | The amount is above the mandate's per-transaction limit. |
CURRENCY_MISMATCH | DECLINE | The request currency differs from the mandate currency. |
MCC_NOT_ALLOWED, MCC_BLOCKED | DECLINE | The MCC is outside the mandate's allowlist, or on its blocklist. |
MERCHANT_NOT_ALLOWED | DECLINE | The merchant is not on the mandate's merchant allowlist. |
COUNTRY_NOT_ALLOWED, COUNTRY_BLOCKED | DECLINE | The country is outside the mandate's allowlist, or on its blocklist. Codes are compared as ISO 3166-1 alpha-3. |
MANDATE_SCOPE_UNVERIFIABLE | DECLINE | The mandate constrains a field (MCC, merchant or country) that the request did not send. When that field has an allowlist, it follows MCC_NOT_ALLOWED, MERCHANT_NOT_ALLOWED or COUNTRY_NOT_ALLOWED. |
OUTSIDE_TIME_WINDOW, DAY_NOT_ALLOWED | DECLINE | Outside the mandate's allowed hours or days. |
DAILY_AMOUNT_EXCEEDED, DAILY_COUNT_EXCEEDED, MONTHLY_AMOUNT_EXCEEDED, MONTHLY_COUNT_EXCEEDED | DECLINE | The transaction would exceed a velocity cap on the mandate. |
CHAIN_NOT_ALLOWED, ASSET_NOT_ALLOWED, COUNTERPARTY_NOT_ALLOWED, WALLET_ADDRESS_MISMATCH | DECLINE | Crypto rail: the chain, asset or recipient is outside the mandate, or the transfer does not come from the agent's bound wallet. |
RAIL_NOT_ENABLED | DECLINE | The requested rail is not enabled for the agent's Program, or in this environment. |
RISK_SCORE_TOO_HIGH | DECLINE or STEP_UP | The agent's risk score is at or above the hard-decline or step-up threshold. |
KYA_SCORE_TOO_LOW | DECLINE or STEP_UP | The KYA Score is below the decline floor (0.15) or the step-up threshold (0.40), platform defaults. |
PEAK_EXPOSURE_STEP_UP | STEP_UP | When enabled for your account: an established agent is paying a new counterparty an amount that is high for its own history. |
PRINCIPAL_VELOCITY_EXCEEDED | DECLINE or STEP_UP | When enabled for your account: spending across all of the Principal's agents exceeds its aggregate limit. |
SYSTEM_UNAVAILABLE | Per fail policy | A dependency needed for the decision was unavailable. Your fail policy applies (fail closed by default). |
Detectors that are still rolling out can add further codes (lowercase, for example structuring_pattern). Log codes you do not recognize rather than failing on them.
Code examples
A complete authorization call with decision handling. Send your credential in the X-API-Key header (see Authentication), then branch on decision:
curl -X POST https://api.mandatelabs.ai/api/v1/authorize \ -H "X-API-Key: mdt_live_…" -H "Content-Type: application/json" \ -d '{ "agent_id": "agt_…", "amount": "149.99", "currency": "USD", "merchant_name": "Amazon Web Services", "mcc": "5734", "country": "USA", "idempotency_key": "ops-20260525-0042", "intent_context": { "intent_type": "PURCHASE", "task_reference": "cloud_infrastructure_scaling", "reasoning_summary": "Auto-scaling triggered by a traffic spike; reserved capacity is cheaper than on-demand for the next 30 days", "confidence": 0.84, "alternatives_considered": 2, "session_id": "sess_daily_ops_20260525" } }' # Response: "decision" is "APPROVE", "DECLINE" or "STEP_UP", # with reason_codes, reason_detail, kya_score, risk_score and risk_assessment.
import httpx resp = httpx.post( "https://api.mandatelabs.ai/api/v1/authorize", headers={"X-API-Key": "mdt_live_…"}, json={ "agent_id": "agt_…", "amount": "149.99", "currency": "USD", "merchant_name": "Amazon Web Services", "mcc": "5734", "country": "USA", "idempotency_key": "ops-20260525-0042", "intent_context": { "intent_type": "PURCHASE", "task_reference": "cloud_infrastructure_scaling", "reasoning_summary": "Auto-scaling triggered by a traffic spike; reserved capacity is cheaper than on-demand for the next 30 days", "confidence": 0.84, "alternatives_considered": 2, "session_id": "sess_daily_ops_20260525", }, }, ) result = resp.json() if result["decision"] == "APPROVE": process_payment(result["authorization_id"]) elif result["decision"] == "STEP_UP": # A challenge is open for 5 minutes; confirm or deny it once the principal answers queue_for_confirmation(result["authorization_id"], result["reason_codes"]) else: # DECLINE log_decline(result["reason_codes"], result["reason_detail"])
const resp = await fetch("https://api.mandatelabs.ai/api/v1/authorize", { method: "POST", headers: { "X-API-Key": "mdt_live_…", "Content-Type": "application/json" }, body: JSON.stringify({ agent_id: "agt_…", amount: "149.99", currency: "USD", merchant_name: "Amazon Web Services", mcc: "5734", country: "USA", idempotency_key: "ops-20260525-0042", intent_context: { intent_type: "PURCHASE", task_reference: "cloud_infrastructure_scaling", reasoning_summary: "Auto-scaling triggered by a traffic spike; reserved capacity is cheaper than on-demand for the next 30 days", confidence: 0.84, alternatives_considered: 2, session_id: "sess_daily_ops_20260525", }, }), }); const result = await resp.json(); if (result.decision === "APPROVE") { processPayment(result.authorization_id); } else if (result.decision === "STEP_UP") { // A challenge is open for 5 minutes; confirm or deny it once the principal answers queueForConfirmation(result.authorization_id, result.reason_codes); } else { logDecline(result.reason_codes, result.reason_detail); }
To review past decisions for an agent, query the authorization history (newest first). List responses return a data array and a pagination object; pass pagination.next_cursor as cursor to fetch the next page.
curl "https://api.mandatelabs.ai/api/v1/authorize/log/agent/agt_…?limit=10" \ -H "X-API-Key: mdt_live_…"