Skip to content

How to simulate chargebacks for disputes testing

Disputes testing triggers chargebacks in processor sandboxes with the published test cards, amounts, or cardholder names, then replays stages the sandboxes cannot reach, such as pre-arbitration and second chargebacks, as signed synthetic webhooks. The check that catches most defects compares each closed dispute's ledger entries with the amount, fee, and exchange difference its outcome requires.

Where dispute handling breaks before the first real chargeback

A dispute handler written against one sandbox run usually knows three states: opened, won, lost. Real disputes move through more. On Adyen, a defended chargeback sends CHARGEBACK_REVERSED and returns the disputed amount, and the issuer can still decline the defence, which ends in SECOND_CHARGEBACK and a second debit (Adyen dispute flow, checked on 2026-09-22). A handler that closes the case as won on the reversal leaves the merchant balance overstated by the disputed amount. A chargeback management product that bills a percentage of wins invoices a fee on a case that ends lost.

Three other defects reach production the same way:

  • Evidence sent twice. Stripe's update dispute call submits evidence to the bank unless submit=false is passed (Stripe API, checked on 2026-09-22). A "save draft" job without that flag sends an incomplete response, and section 11.2.1 of the Visa Rules forbids an acquirer from responding more than once to the original Dispute.
  • Fee booked into the sale. The dispute fee posts against the order, so the order's refundable balance drops and a later win credits the fee back where the pricing agreement keeps it.
  • Inquiry closed as a win. Stripe marks an inquiry warning_closed after 120 days without escalation and states that card networks send no explicit win for inquiries (Stripe, how disputes work, checked on 2026-09-22). Mapping warning_closed to "won" inflates the win rate reported to merchants.

Network rules below cite the Visa Core Rules and Visa Product and Service Rules of 18 April 2026, a global document whose region- and country-marked rules apply only to members in that region or country (checked on 2026-09-22). Rule mapping per network, deadlines, and evidence templates are covered by chargeback and dispute testing; failure patterns by revenue model are on the chargeback management segment page. This guide covers the simulation itself.

Procedure: sandbox triggers, harness replay, ledger assertions

1. Write the lifecycle as your own state model

Visa runs two stage orders. For categories 10 (Fraud) and 11 (Authorization), Table 11-1 lists Dispute, pre-Arbitration attempt by the acquirer, pre-Arbitration response by the issuer, and Arbitration. For categories 12 (Processing Errors) and 13 (Consumer Disputes), Table 11-2 lists Dispute, Dispute Response by the acquirer, pre-Arbitration attempt by the issuer, pre-Arbitration response by the acquirer, and Arbitration. Section 11.3.3 lets a member reverse its own action no later than 31 calendar days after its Processing Date, if the other member has not moved to the next stage and neither has accepted liability.

Map each processor status to one internal state and list the allowed transitions. Anything outside the list fails the test instead of silently overwriting the state.

Python
from enum import Enum

class S(Enum):
    INQUIRY = "inquiry"
    OPEN = "open"                      # funds withdrawn, response possible
    RESPONDED = "responded"            # evidence submitted once
    REVERSED = "reversed"              # funds returned, issuer may still act
    PRE_ARB = "pre_arbitration"
    ARBITRATION = "arbitration"
    WON = "won"
    LOST = "lost"
    INQUIRY_CLOSED = "inquiry_closed"  # never counted as a win

ALLOWED = {
    S.INQUIRY: {S.OPEN, S.INQUIRY_CLOSED},
    S.OPEN: {S.RESPONDED, S.LOST},
    S.RESPONDED: {S.REVERSED, S.PRE_ARB, S.LOST, S.WON},
    S.REVERSED: {S.PRE_ARB, S.LOST, S.WON},   # Adyen SECOND_CHARGEBACK -> LOST
    S.PRE_ARB: {S.ARBITRATION, S.WON, S.LOST},
    S.ARBITRATION: {S.WON, S.LOST},
    S.LOST: {S.WON},                           # Stripe "late win"
}

ADYEN = {
    "NOTIFICATION_OF_CHARGEBACK": S.OPEN, "CHARGEBACK": S.OPEN,
    "INFORMATION_SUPPLIED": S.RESPONDED, "CHARGEBACK_REVERSED": S.REVERSED,
    "SECOND_CHARGEBACK": S.LOST, "PREARBITRATION_OPEN": S.PRE_ARB,
    "PREARBITRATION_WON": S.WON, "PREARBITRATION_LOST": S.LOST,
    "SCHEME_ARBITRATION": S.ARBITRATION,
    "SCHEME_ARBITRATION_WON": S.WON, "SCHEME_ARBITRATION_LOST": S.LOST,
}

STRIPE = {
    "warning_needs_response": S.INQUIRY, "warning_under_review": S.INQUIRY,
    "warning_closed": S.INQUIRY_CLOSED, "needs_response": S.OPEN,
    "under_review": S.RESPONDED, "won": S.WON, "lost": S.LOST,
}

def transition(current: S, new: S) -> S:
    if new is current:
        return current                 # duplicate delivery, no second posting
    if new not in ALLOWED.get(current, set()):
        raise ValueError(f"illegal dispute transition {current.value} -> {new.value}")
    return new

The Adyen event codes come from its dispute webhook list and the Stripe statuses from the Dispute object (both checked on 2026-09-22). Stripe's prevented status needs its own decision in your model. Stripe also documents a late win, where a lost dispute changes to won and funds return, which is why LOST keeps one exit.

2. Trigger disputes in each sandbox

Every trigger below uses published test values only. Keep live card numbers out of the test environment: cardholder data in pre-production is outside what PCI DSS requirement 6.5.5 allows unless that environment is in the cardholder data environment and fully protected. The test card numbers guide lists which sandbox publishes which range.

Stripe creates a dispute from a PaymentMethod test token, so no card number crosses your code (Stripe testing, checked on 2026-09-22). Amounts are in minor units; 10000 is EUR 100.00.

Request
POST /v1/payment_intents HTTP/1.1
Host: api.stripe.com
Authorization: Bearer sk_test_EXAMPLE_NOT_A_KEY
Content-Type: application/x-www-form-urlencoded

amount=10000&currency=eur&confirm=true&payment_method=pm_card_createDisputeProductNotReceived&automatic_payment_methods[enabled]=true&automatic_payment_methods[allow_redirects]=never

Other Stripe tokens create a fraudulent dispute (pm_card_createDispute), an inquiry (pm_card_createDisputeInquiry), an early fraud warning, multiple disputes on one charge, and a Visa Compelling Evidence 3.0 eligible dispute.

Adyen reads the scenario from holderName; you capture the payment, and the chargeback arrives within 24 hours (Adyen chargeback test scenarios, checked on 2026-09-22). The card is Adyen's published Visa test card 4111 1111 4555 1142, expiry 03/2030, CVC 737, sent with the test_ prefix Adyen documents for unencrypted test values (Adyen test card numbers, checked on 2026-09-22).

Request
POST /v71/payments HTTP/1.1
Host: checkout-test.adyen.com
X-API-Key: EXAMPLE_TEST_API_KEY
Content-Type: application/json

{
  "merchantAccount": "ExampleTestMerchant",
  "reference": "dispute-sim-0007",
  "amount": { "currency": "EUR", "value": 10000 },
  "paymentMethod": {
    "type": "scheme",
    "encryptedCardNumber": "test_4111111145551142",
    "encryptedExpiryMonth": "test_03",
    "encryptedExpiryYear": "test_2030",
    "encryptedSecurityCode": "test_737",
    "holderName": "Chargeback:10.4 9000 EUR"
  },
  "returnUrl": "https://shop.example.com/checkout/return"
}

Chargeback:10.4 9000 EUR requests a partial chargeback of EUR 90.00 on the EUR 100.00 payment. Adyen caps the test difference at 50 percent below the payment amount and 20 percent above it. Chargeback Reversed and Second Chargeback produce the defended and re-debited paths.

Checkout.com selects the scenario from the amount of a payment made with test card 4242 4242 4242 4242, expiry 01/2099 (Checkout.com disputes testing, checked on 2026-09-22). Its GBP trigger values, as published:

Data
scheme,api_amount_gbp_minor,reason_code,resulting_status,evidence_required
visa,1040,10.4,evidence_required,yes
visa,1310,13.1,evidence_required,yes
visa,1045,10.4,won,yes
visa,1046,10.4,lost,yes
visa,1042,10.4,resolved,no
mastercard,4855,4855,evidence_required,yes
mastercard,4850,4855,won,yes
mastercard,4851,4855,lost,yes

3. Submit evidence and drive the outcome

In Stripe test mode, the evidence text sets the outcome: winning_evidence closes the dispute as won, losing_evidence as lost, and escalate_inquiry_evidence turns an inquiry into a chargeback. Stage the evidence first, then submit it in a second call, and assert that evidence_details.submission_count equals 1 afterwards.

Request
POST /v1/disputes/du_EXAMPLE0001 HTTP/1.1
Host: api.stripe.com
Authorization: Bearer sk_test_EXAMPLE_NOT_A_KEY
Content-Type: application/x-www-form-urlencoded

evidence[uncategorized_text]=winning_evidence&evidence[shipping_tracking_number]=SYNTH-TRACK-0001&submit=false

Accepting a dispute is POST /v1/disputes/{id}/close, which Stripe describes as irreversible. Your product should show that as a loss, with no refund button: Stripe blocks refunds outside the dispute process while a dispute is open.

4. Replay the stages the sandbox does not reach

Stripe states that it does not support the arbitration phase, and none of the trigger lists cited above includes a pre-Arbitration outcome, an Arbitration outcome, or a member reversal under section 11.3.3. Your harness sends those events to your own webhook endpoint, signed with the sandbox's test HMAC key, in the processor's real payload format.

JSON
{
  "live": "false",
  "notificationItems": [
    {
      "NotificationRequestItem": {
        "eventCode": "PREARBITRATION_LOST",
        "success": "true",
        "pspReference": "SYNTHDISPUTE000001",
        "originalReference": "SYNTHPAYMENT000001",
        "merchantAccountCode": "ExampleTestMerchant",
        "merchantReference": "dispute-sim-0007",
        "amount": { "currency": "EUR", "value": 9000 },
        "eventDate": "2026-11-04T10:15:00+01:00",
        "reason": "Synthetic pre-arbitration decline",
        "additionalData": {
          "chargebackReasonCode": "10.4",
          "disputeStatus": "Lost",
          "hmacSignature": "computed-by-harness"
        }
      }
    }
  ]
}

Send each synthetic sequence twice and in reverse order. The state model must reject CHARGEBACK_REVERSED before INFORMATION_SUPPLIED or park it until the earlier event arrives, and a duplicate must post nothing. For fraud disputes, add one case on a payment authenticated with EMV 3-D Secure: Stripe notes that its fraudulent dispute type is protected after 3D Secure authentication, so the case record must carry the authentication result the response relies on.

5. Assert the ledger per outcome

Run this after every simulated sequence. It lists closed disputes whose postings differ from what their outcome requires.

SQL
WITH posted AS (
  SELECT d.dispute_id, d.processor, d.outcome, d.disputed_amount_minor,
         SUM(CASE WHEN j.entry_type = 'dispute_debit'      THEN j.amount_minor ELSE 0 END) AS debit,
         SUM(CASE WHEN j.entry_type = 'dispute_reinstated' THEN j.amount_minor ELSE 0 END) AS reinstated,
         SUM(CASE WHEN j.entry_type = 'dispute_fee'        THEN j.amount_minor ELSE 0 END) AS fee,
         SUM(CASE WHEN j.entry_type = 'dispute_fee_return' THEN j.amount_minor ELSE 0 END) AS fee_return,
         COUNT(*) FILTER (WHERE j.account = 'sales_revenue') AS fee_on_sale
  FROM disputes d
  JOIN journal_entries j ON j.dispute_id = d.dispute_id
  WHERE d.state IN ('won', 'lost')
  GROUP BY d.dispute_id, d.processor, d.outcome, d.disputed_amount_minor
)
SELECT p.*, f.returns_received_fee_on_win
FROM posted p
JOIN fee_schedule f ON f.processor = p.processor
WHERE p.debit <> -p.disputed_amount_minor * (SELECT COUNT(*) FROM dispute_events e
                                             WHERE e.dispute_id = p.dispute_id AND e.moves_funds = 'debit')
   OR (p.outcome = 'won'  AND p.reinstated + p.debit <> 0)
   OR (p.outcome = 'lost' AND p.reinstated + p.debit <> -p.disputed_amount_minor)
   OR (p.outcome = 'won'  AND (p.fee_return <> 0) <> f.returns_received_fee_on_win)
   OR p.fee_on_sale > 0;

The query expects one debit per funds-moving event, so the Adyen reversed then second chargeback path shows two debits and one reinstatement, netting to one lost amount. The ledger reconciliation guide covers matching these postings to processor settlement reports.

Pitfalls the sandbox documentation does not flag

A full refund after the Dispute does not close a cross-currency case. Table 11-2 lets the issuer pursue pre-Arbitration under the same Dispute condition when both apply: the merchant issued a credit for the full Transaction amount in the merchant's local currency after the Dispute was initiated, and the issuer lost money on the exchange rate difference between the credit and the Dispute Response amount. Take a synthetic sale of EUR 100.00 billed to a USD card. The Dispute debits the merchant EUR 104.20 at the later rate, the merchant refunds EUR 100.00, the acquirer's Dispute Response cites that credit, and the case is marked resolved. The issuer's pre-Arbitration attempt then arrives within 30 calendar days of the Dispute Response Processing Date on a case your system closed, and it goes unanswered. Section 11.4.2 makes the party assigned or accepting final liability responsible for the exchange difference, so the EUR 4.20 needs its own ledger account and an owner decided at closure. Test it by keeping such cases open until that window passes and by asserting the difference posts to a foreign exchange account, never to the sale.

The sandbox balance is a poor oracle for fees. Stripe test mode says winning_evidence credits the charge amount and related fees. In live mode, outside Mexico, Stripe never returns the dispute received fee unless the contract says otherwise, and returns the dispute countered fee on a win where that fee applies. A ledger test that expects the balance after a sandbox win to equal the balance before the dispute passes in test and misstates every live win by the fee. Expected values come from your fee schedule, which is why the query above reads fee_schedule instead of the processor balance.

Test matrix for simulated disputes

ScenarioHow to triggerExpected stateLedger check
Full chargeback, acceptedStripe pm_card_createDispute, then closelostOne debit of the dispute amount, one fee
Partial chargebackAdyen Chargeback:10.4 9000 EURopenDebit of EUR 90.00, sale balance keeps EUR 10.00 refundable
Evidence staged, then submittedStripe submit=false, then submit=truerespondedsubmission_count equals 1
Win on first responseCheckout.com amount 1045 GBP minorwonDebit and reinstatement net to zero, fee per schedule
Loss on first responseCheckout.com amount 1046 GBP minorlostNet debit equals dispute amount
Defended, then re-debitedAdyen Second ChargebacklostTwo debits, one reinstatement, no win fee invoiced
Inquiry expiresSynthetic warning_closedinquiry_closedNo postings, excluded from win rate
Pre-Arbitration lostSynthetic PREARBITRATION_LOSTlostNet debit equals dispute amount, no win fee
Refund after cross-currency DisputeSynthetic sequence, EUR sale, USD cardopen until pre-Arbitration window endsExchange difference on its own account
Duplicate and reordered webhooksHarness replays each sequence twice, reversedunchangedNo second posting
Late win after lossSynthetic lost then wonwonOne reinstatement

Scope your testing

Send FintechAssuranceLabs your product, segment, money flows, and the standards that need evidence, and get a proposed test scope with its deliverables.

Scope your testing