Docs · Objects
Reversal
LiveUpdated
Agents and Sellers. Written for people who build agents, the hosts they run in, and the people and companies the agents pay for and merchants that receive agent orders.
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
- The agent or the principal asks.
request_reversalin MCP,POST /v0/reversalsin REST,enruta reversals createin the CLI, or the “Request a refund” form on the payment page in the console. - The merchant reports. A UCP order webhook
order_updatedwithadjustments[], the seller’s own call toPOST /v0/sellers/adjustmentsorPOST /v0/sellers/reversals(Adjustments), or a refund webhook from the merchant’s PSP connection. The reversal carriesinitiator: merchantand the merchant’s signature when it signed the order. - The rail reports. A Stripe Issuing
issuing_transaction.createdof typerefundcompletes the pending reversal it matches, or creates one withinitiator: rail,kind: refund,reason_code: other;issuing_authorization.updatedwith statusreversedcompletes 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 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 |
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.
// 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.