# API

REST reference for payment requests and clarifications, merchant and agent identity, the money split, recurrences, reversals, settlements, disputes, reconciliation, the sellers and platforms APIs, and webhooks. v0.

Status: Live · Updated: 2026-10-23
Source: https://enruta.ai/docs/api

Base URL `https://api.enruta.ai/v0`. JSON in, JSON out. One endpoint starts everything: request payment. The rest reads what it produced, or reverses, settles, reconciles and disputes it.

## Authentication

`Authorization: Bearer <key>`. Keys belong to an organization and carry a role. **Agent keys** (`enruta_agt_…`) create payment requests and reversals and read what they produced. **Developer keys** (`enruta_dev_…`) manage policies, rails, webhooks, consents, attestations and agents, and read everything. **Auditor keys** (`enruta_aud_…`) read records, reconciliation and disputes and export. **Seller keys** (`enruta_slr_…`) act for a seller organization’s verified domains only. **Platform keys** (`enruta_plt_…`) reach `/v0/platform/*` and, with the `Enruta-Account` header, act for one connected account ([Platform accounts](https://enruta.ai/docs/platform-accounts)). **Catalog keys** (`enruta_cat_…`) write and read the account’s catalog through the Catalog API (`/v0/catalog`) and reach nothing else; an account that uses Discovery makes them in Settings › Developers › API keys, and they are the only keys of an account that uses Discovery alone. The server stores a key’s SHA-256 and an eight-character prefix, never the key.

## Idempotency and paging

`POST /v0/payment-requests` requires an `Idempotency-Key` header. A repeated key returns the original response with `decision.reasons` including `idempotent_replay`; the same key with a different body is `409`. Keys live 24 hours. A decision that fails inside Enruta after it has started (the database, the key store, a rail connection missing its stored credentials) is rolled back and nothing is sealed, so the same key decides again. A new request for the same payment takes a new key; `decision.retryable` and `decision.retry_after` say whether one may follow ([retrying](https://enruta.ai/docs/decision#retrying)). Every list endpoint pages with `cursor` and `limit`.

## Payment requests

### POST /v0/payment-requests

Ask to pay. Returns the decision, and the mandate and credential on `allow` or `observe`. Requires `Idempotency-Key`.

**curl**

```bash
curl https://api.enruta.ai/v0/payment-requests \
  -H "Authorization: Bearer $ENRUTA_AGENT_KEY" \
  -H "Idempotency-Key: a3c9…" \
  -H "Content-Type: application/json" \
  -d '{
    "agent_id": "agt_procurement_02",
    "payee": { "name": "Ream & Rule", "domain": "reamandrule.com" },
    "amount": { "value": "24.98", "currency": "USD" },
    "purpose": "2 packs of ballpoint pens for the office",
    "checkout": { "protocol": "ucp", "reference": "chk_01M2511XQ4N7B8T2R6H9K3W5MD" },
    "evidence": [{ "type": "checkout", "ref": "https://reamandrule.com/checkout-sessions/chk_01M2511XQ4N7B8T2R6H9K3W5MD", "digest": "sha256:4b1c…" }],
    "intent": { "text": "Order two packs of pens, keep it under $30", "slots": { "quantity": 2, "max_total": "30.00" } },
    "catalog_snapshot_hash": "sha256:0d3e…"
  }'
```

**TypeScript**

```ts
import { Enruta } from "@enruta/sdk";
const enruta = new Enruta(process.env.ENRUTA_AGENT_KEY!);

const r = await enruta.paymentRequests.create({
  agentId: "agt_procurement_02",
  payee: { name: "Ream & Rule", domain: "reamandrule.com" },
  amount: { value: "24.98", currency: "USD" },
  purpose: "2 packs of ballpoint pens for the office",
  checkout: { protocol: "ucp", reference: "chk_01M2511XQ4N7B8T2R6H9K3W5MD" },
  intent: { text: "Order two packs of pens, keep it under $30", slots: { quantity: 2, max_total: "30.00" } },
}, { idempotencyKey: "a3c9…" });

if (r.decision.result === "allow" || r.decision.result === "observe") pay(r.mandate!.credential);
```

**Response**

```http
201 Created
{
  "id": "preq_01M2511Z7H3K9Q2W5T8N4B6RXD",
  "state": "decided",
  "decision": { "result": "allow", "reasons": [], "policy": { "id": "pol_office_supplies", "version": 4 },
                "checks": [{ "name": "agent_state", "ok": true }, { "name": "consent", "ok": true, "detail": "cns_01M2F0…" }, { "name": "payee_allowlist", "ok": true }, …],
                "retryable": false, "retry_after": null },
  "risk": { "id": "rsk_01M2511ZB2…", "action": "authorized", "score": 0 },
  "mandate": { "id": "mnd_01M2512M8FQ7ZK4T1V9W3N6BXC", "amount": "24.98", "currency": "USD", "expires_at": "2026-09-10T17:57:08Z",
               "mode": "bounded_stepup", "refill_policy": "never", "finality_class": "reversible",
               "retry_policy": { "max_attempts": 3, "window_seconds": 86400, "same_amount_only": true }, "visibility_scope": null, "hidden_constraints_digest": null,
               "jws": "eyJhbGciOiJFUzI1NiIs…",
               "credential": { "type": "token", "handler_id": "enruta_card", "token": "ectok_…" } },
  "approval": null,
  "record_id": "rec_01M25129GZMJXTNQ47TMZRDGHN"
}
```

Body fields added since v0: `intent { text?, slots? }` (the 0.2 `intent_text`, `constraints` and `intent_mapping` are still accepted), `catalog_snapshot_hash?`, `policy_snapshot_hash?`, `deviations?`. The caller’s IP is captured from the request as `agent_ip`. On `clarify` the response carries `state: awaiting_clarification`, `mandate: null` and `decision.questions[]`; on `step_up` it carries `mandate: null` and `approval { id, url, expires_at }`; on `observe` it carries the mandate and `decision.shadow_result`. Every decision carries `retryable` and `retry_after` ([retrying](https://enruta.ai/docs/decision#retrying)), and a mandate carries `retry_policy`, `visibility_scope` and `hidden_constraints_digest` (`null` when the policy sets none), never the values it keeps from the merchant. `complete_checkout: true` (UCP checkouts only) asks Enruta to execute the checkout itself and adds `order` and `completion` to the response. When the rail cannot issue a credential for an allowed payment, the response carries `state: handed_off`, `mandate: null` and `handoff { rail, reason, message, next_step }` instead, and with `complete_checkout` a `failed` completion whose `error` says the payment was handed off and why (see “When the rail cannot issue” below).

### GET /v0/payment-requests/{id}

The request, its current decision (with `retryable` and `retry_after`), the approval and mandate state, `order` and `completion` when Enruta placed the order, `outcome`, `handoff`, and since this release `risk`, `settlement`, `reversals[]`, `reconciliation`, `disputes[]` and `fulfillment` summaries. `state` is one of `decided`, `awaiting_clarification`, `awaiting_approval`, `approved`, `handed_off`, `declined`, `expired`, `executed`, `completed`. Poll this after a `step_up` (`?wait=25` holds the request open until something changes), or subscribe to the webhook.

### POST /v0/payment-requests/{id}/handoff

```json
{ "continue_url": "https://shop.example/cart/c/…",
  "order": { "id": "gid://shopify/Order/…", "status": "completed", "permalink_url": "https://…", "total": { "amount": "699.95", "currency": "USD" } } }
```

For merchants that complete checkout only on their own page: the checkout was handed to a person at the merchant’s `continue_url`. Without `order`: the request moves to `handed_off`, the mandate to `void`, the budget reservation is kept. With `order` (on the first call or a later one): the order becomes the receipt (`self_asserted`), `outcome.status` becomes `completed`, and the record seals. Repeats are idempotent. A request the rail handed off (below) has no checkout and no order to report: the call answers `409`.

### POST /v0/payment-requests/{id}/pay-resource

```http
{ "url": "https://data.example/v1/prices?symbol=ACME",
  "method": "GET",
  "headers": { "Accept": "application/json" } }

200 {
  "paid": true,
  "request_id": "preq_01M3E6…", "mandate_id": "mnd_01M3E6…", "rail": "x402", "authorization_id": "x402_01M3E7…",
  "status": 200,
  "headers": { "content-type": "application/json", "payment-response": "eyJzdWNjZXNzIjp0cnVl…" },
  "body": "{ \"symbol\": \"ACME\", \"price\": \"41.20\" }",
  "truncated": false,
  "receipt": { "success": true, "transaction": "0x5d1c…", "network": "eip155:8453" },
  "record_id": "rec_01M3E6…",
  "answered_in_ms": 812
}
```

Pays an HTTP resource that answers `402 Payment Required` (x402 or MPP) with the mandate of an allowed payment request, and returns what the resource answered. Only the request’s own agent key: a developer key gets `403`, another agent’s key `404`. The body is strict: `url`, `method` (`GET` when absent, or `POST`, `PUT`, `PATCH`, `DELETE`), up to 50 `headers` (none that Enruta or the transport sets, such as `Host`, `Content-Length`, `Payment-Signature` or `X-Payment`) and `body`, a string of up to 256 KB that a `GET` never carries. The URL is https, on the payee’s domain, and exactly the request’s `checkout.reference` when the request named one.

The request must hold an issued mandate on an `x402` or `mpp` rail, or an MPP mandate whose payment channel is open. Enruta calls the resource, pays its 402 inside the mandate (never more, and only to the payee) and answers `200` with `paid`, the resource’s `status`, a safe subset of its `headers` (content, caching, redirects and payment receipts), its `body` as text up to 256 KB (`truncated` when cut) and the `receipt` (the x402 `PAYMENT-RESPONSE` or the MPP `Payment-Receipt`). A paid resource seals the payment record, and the settlement record once its stage is final. When the rail refuses, the answer is `paid: false` with `reason` (`amount_exceeds_mandate`, `merchant_mismatch`, `rail_declined`, `rail_timeout`, …) and `detail`, and the mandate stays usable. `400` for a URL off the payee or other than the one the request named, or a malformed body; `409` when no mandate can pay: the approval or the clarification is still open, the payment was handed off or denied, the mandate was used or has expired, or its rail pays no HTTP resources. MCP `pay_http_resource`; CLI `enruta pay-resource`.

### POST /v0/payment-requests/{id}/clarifications

```json
{ "answers": [{ "question_id": "q_max_total", "answer": "100.00" }], "answered_by": "user" }
```

Answers the questions of a `clarify` decision and decides the request again; returns the request. The request’s agent key or a developer key. `answered_by` is `user` unless the body says `agent` ([intent and clarification](https://enruta.ai/docs/intent-and-clarification)).

### GET /v0/payment-requests/{id}/economics

The [money split](https://enruta.ai/docs/economics) of the payment: the flows and each party’s view, every line marked actual, estimate or plan, with its source and date. Agent keys read their own payments; developer and auditor keys any.

## When the rail cannot issue

The policy allowed the payment, or a person approved it, but the rail issued no credential. The request moves to `handed_off` with `mandate: null`, `handoff { continue_url, handed_off_at, order: null, rail, reason, message, next_step }` and `outcome { status: "handed_off", handed_off_at, rail, reason, message, next_step, continue_url? }`. `continue_url` is the rail’s own page when the rail named one, otherwise the payment’s page in the console, under its account (`https://app.enruta.ai/payments/{account}/payments/{id}`). Nothing was paid, the budget reservation is released, the record seals at once with that outcome, an idempotent replay returns the same response, and `payment_request.handed_off` is sent. A rail hand-off is final: `POST /v0/payment-requests/{id}/handoff` answers `409`, and with `complete_checkout: true` no order is placed (`completion: { state: "failed", error: "Handed off to a person: the <rail> rail issued no credential (<reason>: <message>), so no order was placed" }`).

| `reason` | Rail | What it means |
|---|---|---|
| `rail_limit` | Link | Above the 500 USD a Link spend request can carry |
| `seller_profile_missing` | Link, Stripe shared payment token | No Stripe profile is known for the seller |
| `read_only` | Bill.com | The connection reads bills, approvals and payments and approves nothing |
| `bill_missing`, `untrusted_session`, `bill_archived`, `bill_decided`, `vendor_mismatch`, `amount` | Bill.com | No bill id in the checkout, no MFA-trusted session, an archived or already decided bill, a bill from another vendor, or a bill above the mandate |
| `recipient_missing` | Mercury | No active Mercury recipient for the payee |
| `currency` | Mercury, Bill.com, x402 | The rail pays in another currency |
| `not_configured` | Ramp, x402, MPP | The connection lacks the user, the signer or the payment method it needs |
| `rail_unavailable` | Every rail | The rail failed while issuing: it answered 5xx, 408, 425 or 429, timed out, or could not be reached |
| `rail_error` | Every rail | Any other failure while issuing, such as a 4xx refusal for bad credentials |

A payment handed off with `rail_unavailable` or `rail_error` does not count toward the policy’s duplicate window, so the same payment can be requested again once the rail answers or the connection is fixed, and its decision answers `retryable: true`. Every other reason still counts: a person follows `next_step`, and `retryable` is `false`. Under a policy’s `retry_policy`, every hand-off counts as a failed attempt.

## Merchants and agent identity

### GET /v0/merchants/identity?domain=reamandrule.com

Checks a merchant before paying and returns the result with its `domain`: `status` (`verified`, `unverified` or `mismatch`), each check, the seller organization and when. Agent, developer and auditor keys ([trust model](https://enruta.ai/docs/trust-model)).

### POST /v0/attestations/nonce

A fresh nonce for an `agent_key` proof.

### POST /v0/attestations

`{ agent_id, anchor: { type, party?, …evidence } }` verifies an [identity anchor](https://enruta.ai/docs/identity-anchors) and seals a `kya_attestation` record. The v1 body (`registry`, `registry_ref`, `method`, evidence) is still accepted. `GET /v0/attestations` lists attestations with their type, tier and status.

### GET /v0/agents/{id}/identity

The agent’s `{ assurance, anchors }`.

## Recurrences

### POST /v0/recurrences

`{ agent_id, payee, currency, per_occurrence_max, cumulative_max, amount_variance: { type, pct? }, schedule: { interval, anchor }, end_date?, consent_id?, purpose }` creates a [recurrence](https://enruta.ai/docs/recurrence) and seals its record. `GET /v0/recurrences` lists them.

### GET /v0/recurrences/{id}

One recurrence. `PATCH` the same path to pause, resume or end it; every change is sealed. `GET /v0/recurrences/{id}/occurrences` lists the payments made under it.

## Reversals

### POST /v0/reversals

```json
{ "record_id": "rec_01M25129GZMJXTNQ47TMZRDGHN",     // or request_id, or mandate_id
  "kind": "refund",                                   // void | cancel | refund | return | credit | price_adjustment | chargeback_credit
  "amount": { "value": "9.99", "currency": "USD" },  // default: the remaining reversible amount
  "reason_code": "customer_request",
  "refund_to": "original_form_of_payment" }             // or credit

201 { "reversal": { "id": "rev_01M2G8YV5R3T7W2Q9K4N8B6DXP", "stage": "settled", "status": "completed", "fund_change": true,
                    "mandate_effect": "refill_pending_settlement", "reversal_record_id": "rec_01M2G8Z0Q4W7T2K9N5B3D6H1XC", … },
      "decision": { "result": "allow", "checks": [{ "name": "original_amount", "ok": true }, { "name": "stage", "ok": true }, { "name": "finality", "ok": true }, { "name": "window", "ok": true }, { "name": "cancellable_until", "ok": true }] },
      "adjustments": { "ucp": { … }, "acp": { … }, "adyen": { … }, "shopify": { … } } }
```

A reversal is a decision (`allow`, `step_up` above the policy’s `reversal.step_up_above`, or `deny`), executed by stage: a void before authorization, a reversal of an uncaptured authorization, a refund after capture. Agent keys may reverse their own records; developer keys any. Field by field: [Reversal](https://enruta.ai/docs/reversal).

### GET /v0/reversals

Filter with `record_id`, `status`, `from`, `to`; paginated.

### GET /v0/reversals/{id}

One reversal with its `events[]`, `decision`, `approval` and the id of its sealed record.

### GET /v0/reversals/{id}/adjustments?protocol=ucp

The reversal rendered as a merchant-side adjustment for `ucp`, `acp`, `adyen` or `shopify` ([Adjustments](https://enruta.ai/docs/adjustments)).

## Settlements

### GET /v0/settlements/{record_id}

The [settlement record](https://enruta.ai/docs/settlement) of a payment: stage, amounts, `finality_class`, `finality_at`, rail references, fulfillment. Agent (own), developer and auditor keys.

## Disputes

### GET /v0/records/{id}/dispute-packet

Generates (or returns) the [dispute packet](https://enruta.ai/docs/dispute-packet) for a sealed payment record: the packet, its signed JWS, the CE3.0 element checklist and the six exports. `POST` to the same path regenerates it. Developer and auditor keys.

### GET /v0/records/{id}/representment.pdf

The one-page representment PDF, `application/pdf`.

### GET /v0/disputes

Dispute packets and their outcomes. Filter with `state`, `from`, `to`. Agent keys see their own records’ packets.

### POST /v0/disputes/{id}/outcome

```json
{ "outcome": "won", "note": "Issuer accepted the mandate and login id", "submitted_to": "stripe" }
```

Records the outcome (`won`, `lost`, `withdrawn`), seals a `dispute_outcome` record chained to the packet, patches `dispute_afterlife` in the original record’s bundle, and emits `dispute.outcome_recorded`.

## Reconciliation

### GET /v0/reconciliation/lines?period=2026-W37&state=partial

[Reconciliation lines](https://enruta.ai/docs/reconciliation) for an ISO week; `state` filters by `match_state`. `GET /v0/reconciliation/lines/{mandate_id}` returns one line. Developer and auditor keys.

### GET /v0/reconciliation/reports/{period}

The weekly report: counts by match state, exceptions with suggested actions, coverage, root hash, state `draft` or `signed`, the signer and the sealed record ids.

### POST /v0/reconciliation/reports/{period}/build

Builds or rebuilds the draft for the period. The weekly job does this for the previous week every Monday at 01:00 UTC.

### POST /v0/reconciliation/reports/{period}/sign

Signs the draft by link key over the API; a passkey signature is made in the console. Signing seals every line and then the report as linked records.

## Consents and agents

### POST /v0/consents

Creates a [consent record](https://enruta.ai/docs/consent-and-kya) for an agent; `GET /v0/consents` lists them and `GET /v0/consents/{id}` reads one. Developer keys.

### POST /v0/consents/{id}/revoke

```json
{ "effective_at": "2026-10-01T00:00:00Z", "cascades_scheduled": true }
```

Revokes a consent; every field is optional. `effective_at` is now when absent; a later time schedules the revocation and the consent stays `active` until then; a time more than a minute in the past is `400`. Without `cascades_scheduled` new mandates stop and those already issued stay valid until they expire. With `cascades_scheduled: true` what the agent has scheduled stops too: recurrences made under the consent end, its unused mandates are voided, its pending approvals expire, and when no other consent covers the agent its other recurrences pause and the agent is paused. `institution_notified_at` records when a consumer told their institution. Answers `200` with the consent, its `revocation { revoked_at, by, effect, institution_notified_at, effective_at, cascades_scheduled, notified_institution_at, cascade }`, `record_id` and `revoke_record_id`. Idempotent: a revoked consent comes back as it is, and a scheduled revocation stands until it takes effect or one effective now brings it forward. Developer keys ([consent record](https://enruta.ai/docs/consent-and-kya#revoking-a-consent)).

### POST /v0/agents/{id}/pause

Pauses an agent with a reason; every later request is denied with `agent_paused` and the pause is a sealed record. `POST /v0/agents/{id}/resume` lifts it. Developer keys; the console has the same buttons.

## Records, exports, sandbox

### GET /v0/records

Filter with `agent_id`, `from`, `to` and, since this release, `kind` (`payment`, `reversal`, `settlement_record`, …). `GET /v0/records/{id}` serves payment and linked records alike; add `/bundle` for the bundle, `/verify` for a server-side verification report, `/linked` for the records chained to this one.

### POST /v0/exports

```http
{ "from": "2026-09-01", "to": "2026-09-30", "principal_login_id": "halvern/dana.reyes@halvernrobotics.com", "kinds": "all", "consents": true }
→ 202 { "id": "exp_01M2H4…" }
GET /v0/exports/{id} → a signed download URL when ready
```

An export is a zip: one `.json` per record with its `.bundle.json`, `linked/` for the linked records in range, `consents/` for the sealed consent records, the daily checkpoints, the verifier, the schema and a README with the verify command. The three new fields are optional; an export without them is exactly the v0 export. See [Compliance export](https://enruta.ai/docs/compliance-export).

### POST /v0/sandbox/keys

No authentication, five per IP per hour. Creates a sandbox organization with an agent key, a developer key, the sample policy and a claim URL a person can open later ([quickstart](https://enruta.ai/docs/quickstart)).

## Approvals and the organization

### POST /v0/approval-delegations

Delegates approval to a member or an e-mail address within a scope `{ max_amount, payees?, agents?, until }`. `GET /v0/approval-delegations` lists them; `DELETE /v0/approval-delegations/{id}` revokes one ([Signed policy](https://enruta.ai/docs/signed-policy)).

### PATCH /v0/org/settings

`slack_webhook_url` (approval requests also post to Slack), `psp_profile` (the merchant’s PSP pricing, used by the money split) and `sso`. Developer keys or the console.

### POST /v0/org/verification

Starts the organization’s verification: a DNS TXT record `enruta-verification=<token>` for a company’s domain, or a Stripe Identity session for an individual (test mode today). A verified organization can back a `legal_entity` anchor.

## Billing

There is no plan and no subscription. `GET /v0/billing` is the overview: the rate card, the month’s usage and the recent invoices. `GET /v0/billing/rates` is the rate card. `GET /v0/billing/usage` reads a month’s charges: the fees of the successful payments charged to the organization and, for a platform, its sealed records by band. `GET /v0/billing/invoices` lists invoices, `POST` drafts the period’s invoice, and `GET /v0/billing/invoices/{period}` reads one. SDK `billing.*`; console `/billing`.

## Policies and rails

Policies are created and signed in the console, or with a developer key through `POST /v0/policies` (the document; `id` of an existing policy makes it that policy’s next version). The API reads them (`GET /v0/policies`, `GET /v0/policies/{id}/versions/{n}`). Rail connections are created by an administrator in the console with credentials scoped on the rail’s side; the API lists them (`GET /v0/rails`). `POST /v0/rails/authorize` is where a merchant’s payment handler presents the single-use token; `POST /v0/ucp/orders` is the merchant’s order webhook (`order_placed`, `order_updated` with `adjustments[]`, `order_shipped`).

## Sellers

Seller keys use the routes under `/v0/sellers/…`: `orders` (observe and list agent orders), `verify` (check a mandate for a domain), `adjustments` and `reversals` (report or request a reversal), `fulfillments`, `records/{id}/dispute-packet` and `representment.pdf`, `disputes`, `reconciliation`, `conformance`, `middleware`, `domains`. They are documented with the middleware on the [Accept middleware](https://enruta.ai/docs/accept-middleware), [Adjustments](https://enruta.ai/docs/adjustments), [Dispute packet exports](https://enruta.ai/docs/dispute-packet-exports) and [Conformance](https://enruta.ai/docs/conformance) pages. `GET /badges/{domain}.json` and `.svg` are public.

Added for sellers in this release: `GET /v0/sellers/orders/{id}/economics`; `psp_payment` on `POST /v0/sellers/orders`, answered with `psp_write_back`; `GET` and `POST /v0/sellers/psp-connections`, `GET` and `DELETE …/{id}`, `POST …/{id}/test`, `GET` and `POST …/{id}/disputes`, and the PSP webhook `POST /v0/psp/{psp}/webhook/{connection_id}` ([PSP connections](https://enruta.ai/docs/psp-connections)); `GET` and `POST /v0/disputes/{id}/deliveries` ([Dispute delivery](https://enruta.ai/docs/dispute-delivery)); `POST` and `GET /v0/storefronts`, `GET` and `PATCH /v0/storefronts/{id}`, `POST /v0/storefronts/{id}/catalog/refresh`, `GET /v0/storefronts/{id}/orders` ([Storefront](https://enruta.ai/docs/storefront)).

## Platforms

Platform keys use `/v0/platform/…`: `accounts` (create, list, read, update, issue keys), `usage?period=`, `brand`, and `feeds` (create, list, delete, `events`, `replay`); and `GET /v0/records/{id}/psp-metadata?psp=`. With `Enruta-Account`, every other route acts for that account. See [Platform accounts](https://enruta.ai/docs/platform-accounts), [Embedded calls](https://enruta.ai/docs/embedded-calls), [White-label verifier](https://enruta.ai/docs/white-label-verifier), [Dispute field feeds](https://enruta.ai/docs/dispute-field-feeds) and [PSP metadata](https://enruta.ai/docs/psp-metadata).

## Webhooks

| Event | When |
|---|---|
| `payment_request.decided` | A decision was made; its `decision` carries `retryable` and `retry_after` |
| `payment_request.clarification_requested` | A decision asked questions; the request waits for the answers |
| `payment_request.handed_off` | A payment went to a person: the checkout was handed to the merchant’s own page, or the rail issued no credential. The payload carries `request_id`, `agent_id`, `mandate_id`, `continue_url`, `handed_off_at`, `order`, `record_id` and, from a rail, `rail`, `reason`, `message` and `next_step` (with `mandate_id` and `order` null) |
| `approval.completed` | An approval ended: `state` is `approved`, `declined` or `expired`. One that expired because its consent was revoked with `cascades_scheduled` carries `reason: consent_revoked`, and so does one a person tried to approve after the user revoked the consent the decision relied on; one a person tried to approve after the agent was paused carries `reason: agent_paused`. An approval rolled back because its mandate could not be issued sends nothing |
| `mandate.issued` | A mandate exists and can be redeemed |
| `mandate.consumed` | The rail authorized against the mandate |
| `record.sealed` | A record was sealed and time-stamped; carries `kind` |
| `reversal.completed`, `reversal.denied` | A reversal finished or was refused |
| `settlement.updated` | A settlement record changed stage |
| `dispute.outcome_recorded` | An outcome was recorded on a dispute packet |
| `reconciliation.report_ready` | A weekly draft report exists |
| `agent.paused` | An agent was paused |
| `agent.resumed` | A paused agent was resumed |
| `consent.revoked` | A consent revocation took effect (not when it was scheduled); the payload carries `effective_at`, `cascades_scheduled`, `notified_institution_at`, `cascade`, `record_id` and `consent_record_id` |
| `recurrence.created` | A [recurrence](https://enruta.ai/docs/recurrence) was created and its record sealed |
| `recurrence.paused`, `recurrence.resumed` | A recurrence was paused or resumed |
| `recurrence.ended` | A recurrence was ended, also when its schedule reached `end_date` |
| `recurrence.occurrence_created` | An occurrence became a payment request; the payload carries its state and decision |

Deliveries carry an `Enruta-Signature` header: `t=<unix>,v1=<HMAC-SHA256>` over the timestamp and body with your endpoint secret. Retries follow an exponential schedule for 24 hours.

## Errors

| Status | Code | Meaning |
|---|---|---|
| 400 | `invalid_request` | A field is missing or malformed, or looks like a card number |
| 401 | `unauthenticated` | No key, or an unknown key |
| 403 | `forbidden` | The key’s role cannot do this, or a seller key acted outside its verified domains |
| 403 | `account_not_connected` | A platform key named an account that is unknown or suspended |
| 404 | `not_found` | No such object in this account |
| 409 | `idempotency_conflict` | Same key, different body |
| 409 | `invalid_request` | The request is not in a state that allows the call, such as `pay-resource` without a usable mandate or `handoff` after a rail hand-off |
| 429 | `rate_limited` | Slow down; `Retry-After` is set |

A denied payment or a denied reversal is not an error. It is a `201` with `decision.result: "deny"`, and it produces a record.
