View as Markdown

llms.txt

This guide isn't available right now

This instance couldn't load its guide catalog. The guide returns as soon as the catalog is readable again.

Back to the guides

No such guide

This instance publishes no guide under that address. It may have been renamed, or it may belong to a feature this installation hasn't enabled.

Back to the guides

That guide is part of the product documentation

This guide is written for someone operating WinkPG through its screens rather than integrating against it, so it lives in the application's own help section instead of here. Sign in to WinkPG and open Help to read it.

Back to the guides

Guides Payments

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.

Undoing a payment isn't one operation. WinkPG 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 first.

All three run through one endpoint:

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, WinkPG 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 WinkPG 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.

curl "https://your-gateway-host/api/transactions/{transactionId}" \
  -H "api-key: YOUR_API_KEY"
{
  "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 WinkPG 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.

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

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:

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:

{
  "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:

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.

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:

{
  "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.

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

WinkPG 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.

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. WinkPG 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 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:

{
  "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:

{
  "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.

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.

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

WinkPG 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 for the stages these operations act on, how batches close, and what the settlement statuses mean.
  • ACH payments for the bank-debit lifecycle, NACHA return codes, and sandbox return testing.
  • Getting started with the API for authentication, idempotency on the create path, and rate limits.
  • 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.

Reconnecting to the server

Could not reconnect

This session has ended

Attempt 1

Your work on this page is still here. Retrying keeps it; reloading starts the page again.

The server no longer holds this page's state, so it has to be loaded again.