# Refunds, voids, and reversals

Undo a payment over the API: which of the three operations applies, how partial amounts work, and the error codes to branch on.

**Category:** Payments

**Last reviewed:** 1 September 2026

# Refunds, voids, and reversals

Undoing a payment isn't one operation. Syntch has three, they reach different rails, and which one a transaction accepts changes as that transaction moves through settlement. A void and a reversal cancel a charge before the money moves. A refund sends money back after it has. Send the wrong one and the call is refused, not applied incorrectly, so the practical problem is knowing which one to send.

This guide covers the three operations from an integrator's side: what each does, how settlement state decides which is available, how partial amounts work, where ACH differs, and the error codes worth branching on. For the lifecycle these operations sit inside, read [Transaction lifecycle and settlement](/help/guides/transaction-lifecycle-and-settlement) first.

All three run through one endpoint:

```text
POST /api/transactions/by-merchant/{merchantId}/{transactionId}/operations
```

The `by-merchant` segment is the published route. Older single-id shapes still answer and carry a `Deprecation` response header, so build against this one.

## The three operations

| Operation | Reaches the processor | Applies when | Amount | Effect |
|---|---|---|---|---|
| `Void` | Depends on the processor and card type | Before settlement | Full only | The charge is excluded from the next batch close |
| `Reversal` | Yes | Before settlement, inside the processor's reversal window | Full, or part where the processor supports it | The issuer's hold is released and the charge is pulled from the next clearing |
| `Refund` | Yes | After settlement | Full or part | A new `Return` transaction is created, linked back to the original |

**Void** is the ledger-side cancel. It keeps the charge out of the batch so it never settles, and it's final once approved. It's always for the full amount: there's no partial void. Whether a message goes to the processor depends on the processor and the card type, and where one does go, Syntch reports the void as successful only after the processor confirms it.

**Reversal** always sends an online message, so the processor can refuse it. Each processor declares its own reversal window, and once a transaction is older than that window, reversal drops out of the available set even though the charge is still pre-settlement. Where the processor supports partial reversals, you can release part of an amount and leave the remainder authorized.

The two overlap, and Syntch normally offers only one of them per transaction. When a processor takes void as the full-amount cancel and also supports partial reversal, both appear together: void for the whole amount, reversal for part of it. That pair is the one case where you'll see both.

**Refund** is the post-settlement return. The money has already moved, so there's nothing left to cancel. A refund creates a **new** transaction of type `Return`, linked back to the original, and that new transaction runs the normal authorization and settlement pipeline. A refund therefore isn't instant, can be declined, and settles in a later batch. Treating the operation response as proof the money is back is the most common reconciliation bug on this path.

## Settlement state decides which one you get

The rule underneath all of it: **only `SettlementSucceeded` counts as settled**. Before that, the undo is a cancel. After it, the undo is a credit.

| Transaction state | Available undo | Not available, and why |
|---|---|---|
| Authorized, not captured | `Reversal`, `Void` | No `Refund`: no money has moved |
| Captured, batch still open | `Void`, or `Reversal` inside the window | No `Refund`: the batch hasn't cleared |
| Settled | `Refund` | No `Void` or `Reversal`: the batch closed and there's no hold left to release |
| Declined or failed | Neither | Nothing was held, so there's nothing to undo. `Retry` is a separate path, and it's narrower than it looks: see below |
| Approved `Return`, not yet settled | `Reversal`, `Void` | A credit can be pulled back before it clears |
| Settled `Return` | Neither | The credit cleared, and refunding a refund isn't a real operation, so nothing further applies. The endpoint refuses any operation against it with `OPERATION_NOT_ALLOWED_IN_STATE` |
| Zero-dollar verification | Neither | No funds were held. `Void` is refused with `VOID_NOT_APPLICABLE_ZERO_DOLLAR_VERIFICATION` |

Two things sit on top of that table and neither is visible from your side: what the merchant's processor supports, and whether the reversal window has expired. You don't have to work them out. The transaction publishes the operations it accepts, and the next section is how you read them.

**`Retry` on a refused payment isn't a general escape hatch.** It's the one row above where the obvious reading is wrong, so it's worth stating even though retry isn't this guide's subject. Card-network resubmission rules only allow re-running a decline tied to the cardholder's funds or limits, so `Retry` survives for `INSUFFICIENT_FUNDS`, `ACTIVITY_LIMIT_EXCEEDED`, and `EXCEEDS_APPROVAL_AMOUNT` and is withdrawn for every other decline: do-not-honor, lost or stolen, expired card, suspected fraud, and any decline whose reason code the processor didn't supply. A gateway **failure** carries no decline reason code at all, so it's usually left with no follow-up operation, which surprises people who expect a transport fault to be the retryable case. Sending `Retry` anyway is refused rather than attempted. Where a refused payment genuinely needs re-running outside that set, the path is a fresh charge: a cardholder-initiated payment, or a merchant-initiated one under a stored-credential consent.

## Work out which operation applies

Don't hard-code an operation type, and don't derive one from the table above. The transaction carries the answer. Read it and branch on `allowedActions`.

```bash
curl "https://your-gateway-host/api/transactions/{transactionId}" \
  -H "api-key: YOUR_API_KEY"
```

```json
{
  "id": "6f3b2c18-0a4d-4a9e-9d4f-2b71c2f0a911",
  "merchantId": "b41d9f70-6c8a-4a2b-8f31-0d6a2c7e5b14",
  "transactionType": "Sale",
  "currentStage": "Captured",
  "allowedActions": ["Reversal", "Repeat"],
  "settleData": {
    "settlementStatus": "Pending"
  },
  "cumulativeRefundedAmount": null,
  "cumulativeReversedAmount": null
}
```

`allowedActions` is the server's computed set: the operations this transaction accepts right now. It's on every transaction read, the single read above and each row of a list, and it's the same evaluation the platform's own screens run, so the API's answer and the merchant's screen agree.

That set already resolves both unknowns from the previous section, and a third you can't see from your side either. It knows whether the merchant's processor supports reversal, whether the reversal window has expired, and whether the merchant's own settings withhold refunds. The sample above is a captured, unsettled sale offering `Reversal` and not `Void`, because that merchant's processor takes the online undo and Syntch offers one cancel rather than both. A merchant on a processor without it would show `Void` in the same state.

**Read the whole set, then narrow it to the three.** `allowedActions` carries every follow-up operation, not only the undos: `Repeat` appears on an approved charge, `Retry` on a decline the card networks let you resubmit, `Capture` on an authorization that hasn't been captured. Intersect it with the three this guide covers and act on what's left.

```bash
curl -s "https://your-gateway-host/api/transactions/{transactionId}" \
  -H "api-key: YOUR_API_KEY" \
| jq '[.allowedActions[] | select(. == "Refund" or . == "Void" or . == "Reversal")]'
```

```json
["Reversal"]
```

An empty result is an answer rather than a failure: nothing about this transaction can be undone. A decline, an already-voided charge, a settled `Return`, and a zero-dollar verification all land there.

A settled charge can land there too, and when it does the cause is usually a setting rather than the transaction. Refunds are a merchant-level option, and a settled charge has no undo other than the refund, so an account with refunds turned off returns an empty set on a charge that's otherwise refundable. Check **Allow Refunds** in the merchant's Virtual Terminal settings before treating an empty set on a settled charge as a state problem.

**The create response doesn't carry the set.** A transaction you just created comes back without a populated `allowedActions`, because "what can this transaction take now?" is a question for a later read. Where you create a charge and immediately need to know what it accepts, read it back.

`settleData.settlementStatus` and `currentStage` explain the set rather than replace it. `SettlementSucceeded` is what makes the undo a credit instead of a cancel, and nothing else counts as settled. Read them when you need to explain the answer to someone. Branch on `allowedActions`.

**The set is a snapshot, so keep the refusal path.** A batch can close between your read and your operation, which moves the transaction from a cancel to a credit while your request is in flight. The operations endpoint evaluates eligibility again before it submits anything, so an operation that went stale is refused rather than half-applied, and the refusal carries the current set under `error.data.allowedActions`. Treat a `409 OPERATION_NOT_ALLOWED_IN_STATE` as an answer to act on rather than an error to escalate: read the set out of it and re-send.

Watch the shape when you do. On the transaction, `allowedActions` is an array of strings. In the refusal's `data` bag it's a single comma-separated string.

That refusal is also the whole answer for a client that didn't read first. Sending `Reversal` and falling back to `Void` when the refusal names it costs one round trip and no read, which is a fair trade for a client that undoes few enough payments not to wire the GET. Reach for it as a fallback, not as the way to discover which cancel applies: that's what reading the transaction answers, and it answers it before you've moved any money.

## Cancel before settlement

Send the cancel the transaction offered. Both bodies take an optional `reason`, which is kept as an audit note on the transaction.

Reversal:

```bash
curl -X POST \
  "https://your-gateway-host/api/transactions/by-merchant/{merchantId}/{transactionId}/operations" \
  -H "api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "operationType": "Reversal",
    "reason": "Customer cancelled before shipping"
  }'
```

Void:

```bash
curl -X POST \
  "https://your-gateway-host/api/transactions/by-merchant/{merchantId}/{transactionId}/operations" \
  -H "api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "operationType": "Void",
    "reason": "Duplicate order"
  }'
```

Both answer with the same result shape:

```json
{
  "success": true,
  "message": "Reversal submitted successfully.",
  "transactionId": "6f3b2c18-0a4d-4a9e-9d4f-2b71c2f0a911",
  "operationType": "Reversal",
  "errorCode": null,
  "timedOut": false,
  "newTransactionId": null
}
```

`newTransactionId` is null here because a void and a reversal change the original transaction rather than creating one. `success` is the field to branch on, and `errorCode` carries the machine-readable reason when it's false.

The word `submitted` in that message is doing work. The call returns after the durable orchestration confirms the operation landed, so `success: true` means the operation was applied rather than merely queued. It doesn't carry the processor's own answer on a reversal, though. That sits on the transaction, so read the transaction back when you need it.

### Reverse part of an amount

Where the processor supports partial reversals, send an `amount`:

```bash
curl -X POST \
  "https://your-gateway-host/api/transactions/by-merchant/{merchantId}/{transactionId}/operations" \
  -H "api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "operationType": "Reversal",
    "amount": 4.00,
    "reason": "One item out of stock"
  }'
```

The transaction stays open with the remainder still authorized, and `cumulativeReversedAmount` tracks the running total. You can stack further reversals until that total reaches the authorized amount, at which point the transaction is fully reversed and further attempts are refused with `REVERSAL_FULLY_CONSUMED`. An `amount` equal to the whole remaining balance is treated as a full reversal, so it runs on any processor that supports reversal at all. Only an amount below the remaining balance needs partial support, and a processor without it refuses that request with `REVERSAL_PARTIAL_NOT_SUPPORTED`.

## Refund after settlement

Once the batch containing the charge has closed, `settleData.settlementStatus` reads `SettlementSucceeded`, `Refund` is the operation the endpoint accepts, and the two cancels are refused.

```bash
curl -X POST \
  "https://your-gateway-host/api/transactions/by-merchant/{merchantId}/{transactionId}/operations" \
  -H "api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "operationType": "Refund",
    "amount": 4.00,
    "reason": "Returned one item"
  }'
```

The result carries a second transaction id, because the credit is its own transaction:

```json
{
  "success": true,
  "message": "Refund transaction created successfully.",
  "transactionId": "6f3b2c18-0a4d-4a9e-9d4f-2b71c2f0a911",
  "operationType": "Refund",
  "errorCode": null,
  "timedOut": false,
  "newTransactionId": "c2a71e05-9f34-4d81-b6e2-71a0c4d9f832"
}
```

Read that id back to see the outcome. It's a `Return` transaction that authorizes and settles like any other payment, so its own `responseData.resultCode` is what tells you the credit was accepted, and its settlement status is what tells you the money has left.

```bash
curl "https://your-gateway-host/api/transactions/{newTransactionId}" \
  -H "api-key: YOUR_API_KEY"
```

The parent keeps the back-reference: `refundTransactionIds` lists every refund issued against it.

### Partial refunds and refund capacity

Syntch tracks refund capacity on the original transaction, so several partial refunds can never add up to more than the approved amount. Capacity counts refunds that have completed **and** refunds still in flight, which is what stops two concurrent requests from each passing a check the other invalidates.

Three fields on the original transaction report the position:

| Field | Meaning |
|---|---|
| `cumulativeRefundedAmount` | Total already refunded through completed refunds |
| `pendingRefundAmount` | Total reserved for refunds still in flight |
| `refundTransactionIds` | Ids of the `Return` transactions issued against this one |

**Send an explicit `amount` on every refund after the first.** Omitting `amount` asks to refund the original transaction's total, not what's left of it. On a first, full refund that's what you want. On a second refund it asks for more than remains, and the call is refused for exceeding the remaining refundable amount. That refusal has an unusual shape, covered under [Errors to branch on](#errors-to-branch-on).

A refund for a non-positive amount is refused with `REFUND_AMOUNT_INVALID`.

## ACH returns aren't refunds

Bank debits don't follow the card rules above, and the difference matters most on the undo path.

- **There's no hold, so there's no card-style cancel.** ACH has no authorization step and no card batch, so `Capture` and `Reversal` don't apply in the card sense. A debit can still be voided on the processor's side before it's originated to the ACH network, which lands the transaction at the `NotEligible` settlement status.
- **A refund on a cleared debit is a new linked transaction**, same as on a card: it clears on the ACH rail on its own schedule.
- **A return is initiated by the receiving bank, not by you.** The bank refuses the debit and returns it with a NACHA return code such as `R01` for insufficient funds. Syntch records the code and reason on the transaction and moves it to `SettlementRolledBack`. That isn't a refund and you don't request it.
- **A return can arrive after the debit reported as settled.** That's a late return, and it's normal for ACH.
- **A returned debit stops offering `Refund`.** Only `SettlementSucceeded` counts as settled, so a debit at `SettlementRolledBack` is no longer on the settled branch. The bank already pulled the funds back, so there's nothing to credit. Where you still need the money, the next step is a fresh debit once you have good account details, not a refund.
- **The events are different too.** `Transaction.Settled` never fires for ACH. Subscribe to `Transaction.AchStatusChanged` and `Transaction.Returned` instead.

[ACH payments](/help/guides/ach-payments) covers the full ACH lifecycle, the return code series, and how to drive a return in the sandbox without waiting days for one.

## Errors to branch on

Refusals arrive in three shapes on this endpoint, and a client that only reads one of them mishandles the others.

### An HTTP error carrying the code at `error.code`

These are the endpoint's own gates. Nothing was submitted, so nothing needs cleaning up.

| Code | Status | What it means |
|---|---|---|
| `OPERATION_NOT_ALLOWED_IN_STATE` | 409 | The operation isn't in this transaction's allowed set right now, usually because the state moved after you read it. Read `data.allowedActions` off this refusal and send one of those, or wait for the state to change |
| `VOID_NOT_APPLICABLE_ZERO_DOLLAR_VERIFICATION` | 409 | `Void` against a zero-dollar verification. No funds were held, so there's nothing to void |
| `REVERSAL_AMOUNT_EXCEEDS_REMAINING` | 409 | The requested reversal is larger than the remaining reversible balance |
| `REVERSAL_FULLY_CONSUMED` | 409 | The authorization is already reversed in full |
| `OPERATION_IDEMPOTENCY_KEY_CONFLICT` | 409 | The key was already used on this transaction for a different operation type. Use a fresh key |
| `OPERATION_TARGET_NOT_FOUND` | 404 | The transaction doesn't exist, or it belongs to another merchant. Check both identifiers |
| `OPERATION_IDEMPOTENCY_STORE_UNAVAILABLE` | 429 | The dedupe store couldn't be reached, so the operation was refused rather than run unprotected. Retry with the same key |
| `REVERSAL_PARTIAL_NOT_SUPPORTED` | 403 | This processor has no partial reversal. Omit `amount` |
| `REVERSAL_AMOUNT_INVALID` | 403 | The reversal amount isn't positive |
| `REFUND_CAPACITY_EXCEEDED` | 403 | The capacity reservation refused the refund. This is the gate that catches two concurrent refunds racing for the same remaining balance |
| `REFUND_AMOUNT_INVALID` | 403 | The refund amount isn't positive |

`OPERATION_NOT_ALLOWED_IN_STATE` and `VOID_NOT_APPLICABLE_ZERO_DOLLAR_VERIFICATION` carry structured context under `error.data`, so you can branch without parsing the message:

```json
{
  "error": {
    "code": "OPERATION_NOT_ALLOWED_IN_STATE",
    "message": "Operation 'Refund' is not allowed for this transaction in its current state.",
    "data": {
      "operationType": "Refund",
      "allowedActions": "Reversal, Repeat",
      "currentStage": "Captured",
      "settlementStatus": "Pending"
    }
  }
}
```

`allowedActions` in that bag is the same computed set the transaction publishes, re-evaluated at the moment of the refusal, so a client can recover without a second call. Here it's a comma-separated string rather than the array you get on the transaction.

### An HTTP 403 whose `error.code` is the literal `"400"`

A refund goes through the follow-up validator on its way to becoming a `Return` transaction, and that validator has kept its original wire shape for the sake of live integrations. The refusal is **HTTP 403**, `error.code` is the literal string `"400"`, and the code you want is on the matching entry in `error.validationErrors`. An over-capacity refund normally arrives here rather than as `REFUND_CAPACITY_EXCEEDED`:

```json
{
  "error": {
    "code": "400",
    "message": "Refund amount exceeds the remaining refundable amount.",
    "validationErrors": [
      {
        "code": "Transactions:RefundAmountExceedsRemaining",
        "message": "Refund amount exceeds the remaining refundable amount."
      }
    ]
  }
}
```

So don't treat `error.code` as the whole answer on the refund path. Read `error.validationErrors[].code` too, and don't read a 403 here as an authorization problem.

### An HTTP 200 whose body says `success: false`

The request was accepted and something downstream refused it or didn't confirm in time.

| `errorCode` | `timedOut` | What to do |
|---|---|---|
| `REFUND_NOT_ALLOWED_ON_REFUND` | false | A defensive guard, not the refusal you'll meet. A `Return` never offers `Refund`, so the eligibility gate refuses first with `OPERATION_NOT_ALLOWED_IN_STATE`. Don't branch on this one |
| `ORCHESTRATION_TIMEOUT` | true | The operation is still processing. Read the transaction back, or re-send with the same idempotency key to collect the recorded outcome |
| `REFUND_TIMEOUT` | true | Same posture, on the refund path. The reserved amount stays held on the parent until the outcome is known |
| `OPERATION_IN_PROGRESS` | true | A duplicate arrived while the original with this key is still running. No second operation was launched. Poll the transaction |
| `ORCHESTRATION_NOT_AVAILABLE` | false | The operation wasn't submitted. Retry |
| `ORCHESTRATION_FAILED` | false | The operation was submitted and failed. Read the transaction to see where it stopped |
| `ORCHESTRATION_CANCELLED` | true | The caller stopped waiting. The operation was already submitted and may still complete |
| `REFUND_CREATE_FAILED` | false | The refund couldn't be completed and the reserved amount stays held until the outcome is confirmed |
| `COMMUNICATION_ERROR` | false | The operation couldn't be submitted. Retry |

A timeout isn't a failure. On a money-moving operation the work has usually committed server side, so never read `timedOut: true` as "it didn't happen" and re-send without a key. The exhaustive code list, generated from the platform's own catalog, is at [/docs/errors](/docs/errors).

## Retry an operation without doubling it

Send an `idempotencyKey` with any operation. It's scoped to one merchant, transaction, and operation type: re-sending the same operation with the same key executes once and returns the original outcome, including the same `newTransactionId` for a refund.

```bash
curl -X POST \
  "https://your-gateway-host/api/transactions/by-merchant/{merchantId}/{transactionId}/operations" \
  -H "api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "operationType": "Refund",
    "amount": 4.00,
    "idempotencyKey": "order-4471-refund-1",
    "reason": "Returned one item"
  }'
```

Two properties are worth knowing before you rely on it. The replay is unconditional once the original completed, so the recorded outcome comes back even though the operation itself would no longer be allowed in the state it produced. And reusing one key for a **different** operation type on the same transaction is refused as a conflict rather than replayed, so give each logical operation its own key.

This is a different key from the one on the create path. A key you used to create a transaction has no bearing on operations against it.

## Disputes and chargebacks

Syntch doesn't surface disputes or chargebacks today. There's no dispute object on the API, no dispute status on a transaction, no event you can subscribe to for one, and no screen that lists them. A cardholder dispute is raised with their issuer and worked through the acquirer or processor, and that's where you'll see and answer it.

What the platform does contribute is the record. Each transaction keeps the AVS and CVV response codes, the authorization code, the settlement batch identifiers, the stored-credential consent that authorized a merchant-initiated charge, and the fee-disclosure detail the payer saw, which is the evidence a representment usually asks for. If you resolve a dispute by returning the money yourself, that's an ordinary refund on the path above, and the platform has no way to associate it with the dispute. Reconcile the two in whatever system holds your dispute cases.

## See also

- [Transaction lifecycle and settlement](/help/guides/transaction-lifecycle-and-settlement) for the stages these operations act on, how batches close, and what the settlement statuses mean.
- [ACH payments](/help/guides/ach-payments) for the bank-debit lifecycle, NACHA return codes, and sandbox return testing.
- [Getting started with the API](/help/guides/api-getting-started) for authentication, idempotency on the create path, and rate limits.
- [Refund or void a payment](/docs/blueprints/refund-or-void-a-payment) is the runnable version of this guide: it takes two sandbox payments, cancels one before settlement, closes the batch, and refunds part of the other.

## See also

- [All documentation](https://devportal-simpay-sbx.winkpg.io/llms.txt): the machine-readable index of every public page on this site.
