# Reversal

Voids, cancellations, refunds, returns and credits as one object: a decision, a new sealed record chained to the payment, and an explicit effect on the mandate account.

Status: Live · Updated: 2026-09-14
For: agent, seller
Source: https://enruta.ai/docs/reversal

A reversal is never an edit. Every void, cancellation, refund, return, credit or price adjustment is a new record with `chain.relation: reversal_of`, decided against the same policy as the payment, and its effect on the mandate account is a field, not a guess.

## Fields

| Field | Values | Meaning |
|---|---|---|
| `id` | `rev_01M2G8YV5R3T7W2Q9K4N8B6DXP` |  |
| `record_id`, `request_id`, `mandate_id`, `credential_ref` | references | The original payment record, request, mandate and credential |
| `kind` | `void` · `cancel` · `refund` · `return` · `credit` · `price_adjustment` · `chargeback_credit` | A superset of the six UCP, five ACP and five Adyen adjustment types and the five reversal-like Shopify ones; `chargeback_credit` stands alone because it is not the merchant’s wish |
| `stage` | `pre_authorization` · `authorized_uncaptured` · `captured_unsettled` · `settled` | Where the payment was when the reversal was requested; it decides which kinds are possible |
| `amount{value, currency}` | decimal string | Partial amounts allowed; the sum of completed reversals never exceeds the original |
| `initiator`, `initiator_ref` | `agent` · `principal` · `merchant` · `rail` · `enruta_policy`; a readable identity | `enruta_policy` is used for the automatic candidate raised when the rail cleared more than it authorized |
| `reason_code` | `customer_request` · `not_as_described` · `not_received` · `duplicate` · `amount_mismatch` · `policy_violation` · `merchant_cancel` · `fraud_suspected` · `other` |  |
| `merchant_policy_ref{policy_url, policy_hash, consented_at}` |  | The refund or cancellation policy as it stood at purchase |
| `protocol_ref{protocol, adjustment_id, order_id, request_no}` | `ucp` · `acp` · `shopify` · `adyen` | The merchant’s own identifiers |
| `rail_ref{rail, authorization_id, transaction_id, reversal_id, trace_id}` |  | Stripe Issuing `iauth_…` and `ipi_…`, or the sandbox `sbtx_…` |
| `mandate_effect` | `none` · `refill` · `refill_partial` · `refill_pending_settlement` | Derived from the mandate’s `refill_policy` |
| `finality_class` | inherited from the settlement record or the mandate | `irreversible` allows only `credit` |
| `status` | `requested` · `pending` · `completed` · `failed` · `denied` |  |
| `fund_change` | boolean | Only `true` means money actually moved |
| `cancellable_until` | timestamp or null | The rail’s own deadline |
| `refund_to` | `original_form_of_payment` · `credit` | Back to the credential, or a new credit |
| `occurred_at`, `settled_at` |  |  |
| `events[]` | `enruta:reverse:requested` · `:rail-accepted` · `:rail-completed` · `:mandate-refilled` · `:denied` | Every step, sealed |
| `decision`, `approval_id` |  | The reversal’s own decision and, when the refund stepped up, its approval |
| `reversal_record_id` | `rec_…` | The sealed linked record |
| `dedupe_key` | `<order_id>:<adjustment_id>` or the rail transaction id | The same adjustment reported twice creates one reversal |

## Three ways in

1. **The agent or the principal asks.** `request_reversal` in MCP, `POST /v0/reversals` in REST, `enruta reversals create` in the CLI, or the “Request a refund” form on the payment page in the console.
2. **The merchant reports.** A UCP order webhook `order_updated` with `adjustments[]`, the seller’s own call to `POST /v0/sellers/adjustments` or `POST /v0/sellers/reversals` ([Adjustments](https://enruta.ai/docs/adjustments)), or a refund webhook from the merchant’s [PSP connection](https://enruta.ai/docs/psp-connections). The reversal carries `initiator: merchant` and the merchant’s signature when it signed the order.
3. **The rail reports.** A Stripe Issuing `issuing_transaction.created` of type `refund` completes the pending reversal it matches, or creates one with `initiator: rail`, `kind: refund`, `reason_code: other`; `issuing_authorization.updated` with status `reversed` completes a void.

## A reversal is a decision

`evaluateReversal()` runs before anything moves, every check reported in `decision.checks[]`:

| Check | Fails with | Rule |
|---|---|---|
| `original_amount` | `over_original_amount` | `amount + reversed_so_far ≤ original_amount` |
| `stage` | `stage_not_reversible` | `pre_authorization`: only `cancel` or `void`. `authorized_uncaptured`: `void` or `cancel`. `captured_unsettled` and `settled`: `refund`, `return`, `credit`, `price_adjustment`, `chargeback_credit` |
| `finality` | `rail_irreversible` | An `irreversible` rail allows only `credit` |
| `window` | `outside_window` | After settlement, inside the policy’s `reversal.window_days` (default 120) |
| `cancellable_until` | `past_cancellable_until` | Not past the rail’s own deadline |

Any failure denies, and the denial is sealed at once (`status: denied`, `outcome: null`). When the policy sets `reversal.step_up_above` and the initiator is not the merchant, an amount above it steps up: an approval bound to the reversal’s subject hash, signed by passkey or link, the page reading “Refund $9.99 to reamandrule.com”. Otherwise `allow`.

## What it does to the mandate account

| `refill_policy` | `void` / `cancel` | `refund` / `return` / `price_adjustment` after capture | Anything else |
|---|---|---|---|
| `never` (default) | `none` | `none` | `none` |
| `on_void_only` | `refill` | `none` | `none` |
| `on_settled_refund` | `refill` | `refill_pending_settlement`, then `refill` when the rail confirms | `none` |
| `on_any_reversal` | `refill` | `refill` (`refill_pending_settlement` until the rail confirms) | `refill` |

On completion the [mandate account](https://enruta.ai/docs/mandate) gains `refilled += amount` and the policy budget gets the amount back, never below zero. A single-use mandate cannot be spent again; the refill matters to the budget.

## Execution by stage

| Stage | What happens | Result |
|---|---|---|
| `pre_authorization` | The mandate is voided (`void`), the rail releases what it holds, the budget reservation is released | `completed`, `fund_change: false` |
| `authorized_uncaptured` | The rail reverses the authorization when the adapter can (Stripe sandbox keys: `POST /v1/test_helpers/issuing/authorizations/{id}/reverse`) | `completed` on success |
| `captured_unsettled`, `settled`, `refund_to: original_form_of_payment` | The rail refunds when Enruta can drive it (Stripe test keys: an unlinked refund on the card with the payee’s merchant data; sandbox: at once). Otherwise `pending` until the rail reports the refund, or the seller reports its status | `completed`, `fund_change: true` when the rail confirms |
| `refund_to: credit` | No rail movement; the reversal becomes `kind: credit` | `completed`, `fund_change: false` |

> A merchant-initiated refund on a rail Enruta does not drive stays `pending` on purpose: the merchant refunded in its own PSP, and the reversal completes when the buyer’s rail posts the refund or the seller reports `status: completed`. Nothing is marked refunded that the rail did not confirm.

## The record

On completion (or denial) Enruta seals a linked record: `kind: reversal`, `chain: { relation: "reversal_of", of: <payment record>, of_hash }`, `subject` = the reversal with its `events[]`, `decision`, `approval` when any, `outcome: { status: refunded | voided | cancelled, amount, currency, posted_at }`. When the adjustment came signed from the merchant, the bundle carries `artifacts.subject_signature` and the merchant key and `strength.subject` is `third_party_signed`; when a rail event completed it, `artifacts.rail_event`. The original record’s bytes never change.

```json
// the $24.98 order of 2026-09-10, refunded $9.99 by the merchant three days later
{
  "id": "rev_01M2G8YV5R3T7W2Q9K4N8B6DXP",
  "record_id": "rec_01M25129GZMJXTNQ47TMZRDGHN",
  "mandate_id": "mnd_01M2512M8FQ7ZK4T1V9W3N6BXC",
  "kind": "refund", "stage": "settled",
  "amount": { "value": "9.99", "currency": "USD" },
  "initiator": "merchant", "initiator_ref": "reamandrule.com",
  "reason_code": "customer_request",
  "protocol_ref": { "protocol": "ucp", "order_id": "ord_01M2512SM3V3A9R4EBJM8RRRV5", "adjustment_id": "adj_01M2G8Y2K5T8W3Q6N9B1D4H7XR" },
  "rail_ref": { "rail": "stripe_issuing", "authorization_id": "iauth_1S7Kq2…", "transaction_id": "ipi_1S7Rn4…" },
  "mandate_effect": "none",                      // the policy's refill_policy is never
  "finality_class": "reversible",
  "status": "completed", "fund_change": true,
  "refund_to": "original_form_of_payment",
  "occurred_at": "2026-09-13T15:03:40Z", "settled_at": "2026-09-13T15:04:02Z",
  "events": [
    { "type": "enruta:reverse:requested", "at": "2026-09-13T15:03:41Z", "by": "merchant" },
    { "type": "enruta:reverse:rail-completed", "at": "2026-09-13T15:04:02Z", "ref": "ipi_1S7Rn4…" }
  ],
  "decision": { "result": "allow", "reasons": [], "checks": [{ "name": "original_amount", "ok": true }, { "name": "stage", "ok": true }, { "name": "finality", "ok": true }, { "name": "window", "ok": true }, { "name": "cancellable_until", "ok": true }] },
  "reversal_record_id": "rec_01M2G8Z0Q4W7T2K9N5B3D6H1XC",
  "dedupe_key": "ord_01M2512SM3V3A9R4EBJM8RRRV5:adj_01M2G8Y2K5T8W3Q6N9B1D4H7XR"
}
```

## Interfaces

| Surface | Call | Who |
|---|---|---|
| REST | `POST /v0/reversals`, `GET /v0/reversals`, `GET /v0/reversals/{id}`, `GET /v0/reversals/{id}/adjustments?protocol=` | Agent (own records), developer |
| REST, sellers | `POST /v0/sellers/adjustments`, `POST /v0/sellers/reversals` | Seller key, verified domain |
| MCP | `request_reversal` | The agent |
| SDK | `reversals.create(body, { idempotencyKey })`, `.get`, `.list`, `.adjustments(id, protocol)` |  |
| CLI | `enruta reversals list \| get <id> \| create --record <rec> --kind refund --amount 9.99 --reason customer_request` |  |
| Console | `/reversals`, `/reversals/{id}`; “Request a refund” on `/payments/{id}` | Owner, developer |
| Webhooks | `reversal.completed`, `reversal.denied` |  |

A rule for agents, written into the skill: a reversal is a decision; report it, and never retry a denied reversal with another amount.
