A refund agent is not a single API call. It is a decision loop that reads a charge, computes a refund amount, calls a payment provider, and then waits for asynchronous events to confirm what happened. Every one of those steps can be repeated, reordered or lost. Before you write a test, write down the invariant your system must preserve, because the invariant is what the tests will check.
The invariant for refunds has three parts. First, each refund request carries a client-generated idempotency key, and repeating that key must never create a second refund. Second, the sum of all successful refunds for a charge must never exceed the captured amount. Third, every state transition (requested, pending, succeeded, failed) must be replayable without changing the final ledger.
That framing matters because the failure modes are financial, not cosmetic. A handler that returns HTTP 200 while inserting a second refund row is worse than a handler that returns 500 and forces a retry: the first quietly over-refunds, the second is merely slow. As one webhook testing guide puts it, status codes prove delivery acknowledgment, not financial correctness (qaskills.sh).
Payment providers give you the two primitives you need. Stripe stores idempotency keys on POST requests so a retried request returns the original response rather than creating a new object (Stripe idempotent requests). PayPal uses the PayPal-Request-Id header for the same purpose and notes that when two simultaneous requests arrive with the same header, it processes the first and may fail the second (PayPal idempotency guidelines). Your tests must cover both behaviors: same key sequentially, and same key concurrently.
The subtler problem is key generation. A common bug is generating a fresh UUID on each button click or each agent turn, which defeats idempotency entirely; the client has to persist and reuse the key for a given refund intent (Stack Overflow discussion). For an agent, that means the key should be derived from the intent (charge ID plus a stable refund request ID), not minted at call time.



