Enruta

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

FieldValuesMeaning
idrev_01M2G8YV5R3T7W2Q9K4N8B6DXP
record_id, request_id, mandate_id, credential_refreferencesThe original payment record, request, mandate and credential
kindvoid · cancel · refund · return · credit · price_adjustment · chargeback_creditA 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
stagepre_authorization · authorized_uncaptured · captured_unsettled · settledWhere the payment was when the reversal was requested; it decides which kinds are possible
amount{value, currency}decimal stringPartial amounts allowed; the sum of completed reversals never exceeds the original
initiator, initiator_refagent · principal · merchant · rail · enruta_policy; a readable identityenruta_policy is used for the automatic candidate raised when the rail cleared more than it authorized
reason_codecustomer_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 · adyenThe 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_effectnone · refill · refill_partial · refill_pending_settlementDerived from the mandate’s refill_policy
finality_classinherited from the settlement record or the mandateirreversible allows only credit
statusrequested · pending · completed · failed · denied
fund_changebooleanOnly true means money actually moved
cancellable_untiltimestamp or nullThe rail’s own deadline
refund_tooriginal_form_of_payment · creditBack to the credential, or a new credit
occurred_at, settled_at
events[]enruta:reverse:requested · :rail-accepted · :rail-completed · :mandate-refilled · :deniedEvery step, sealed
decision, approval_idThe reversal’s own decision and, when the refund stepped up, its approval
reversal_record_idrec_…The sealed linked record
dedupe_key<order_id>:<adjustment_id> or the rail transaction idThe 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), or a refund webhook from the merchant’s PSP connection. 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[]:

CheckFails withRule
original_amountover_original_amountamount + reversed_so_far ≤ original_amount
stagestage_not_reversiblepre_authorization: only cancel or void. authorized_uncaptured: void or cancel. captured_unsettled and settled: refund, return, credit, price_adjustment, chargeback_credit
finalityrail_irreversibleAn irreversible rail allows only credit
windowoutside_windowAfter settlement, inside the policy’s reversal.window_days (default 120)
cancellable_untilpast_cancellable_untilNot 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 captureAnything else
never (default)nonenonenone
on_void_onlyrefillnonenone
on_settled_refundrefillrefill_pending_settlement, then refill when the rail confirmsnone
on_any_reversalrefillrefill (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

StageWhat happensResult
pre_authorizationThe mandate is voided (void), the rail releases what it holds, the budget reservation is releasedcompleted, fund_change: false
authorized_uncapturedThe 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_paymentThe 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 statuscompleted, fund_change: true when the rail confirms
refund_to: creditNo rail movement; the reversal becomes kind: creditcompleted, 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.

// 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

SurfaceCallWho
RESTPOST /v0/reversals, GET /v0/reversals, GET /v0/reversals/{id}, GET /v0/reversals/{id}/adjustments?protocol=Agent (own records), developer
REST, sellersPOST /v0/sellers/adjustments, POST /v0/sellers/reversalsSeller key, verified domain
MCPrequest_reversalThe agent
SDKreversals.create(body, { idempotencyKey }), .get, .list, .adjustments(id, protocol)
CLIenruta 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
Webhooksreversal.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.