Migrating to 2026-09-01
The breaking change in full: who is affected, how to check your own data, what to change, and how to verify it in sandbox.
Release 2026-09-01 changes how refund headroom is calculated and what
Payment.amount_refunded means. This guide tells you whether you are
affected, how to check from your own data, what to change, and how to verify
the change in sandbox before you pin to the new release. For every change in
the release, see the release history.
Determine Whether You Are Affected
You are affected if any of these is true:
- You compute refund headroom yourself from
amount_refunded, for example asamount - amount_refunded. - You display or reconcile
amount_refundedand expect it to include refunds that have been requested but have not finished. - Your integration can request refunds that together exceed the payment.
The usual source is a retry sent with a new
Idempotency-Key, or a "refund the remainder" job that runs while an earlier refund is still in flight.
The third is the one that catches people, because the old release accepted those requests. Nothing failed, and the over-refund surfaced weeks later in reconciliation.
If none of the three applies, you can move to the new release without changing code.
Check Your Own Data
Run this against your payment and refund records. It lists every payment that has already been refunded more than it was paid:
SELECT p.id AS payment_id,
p.amount,
SUM(r.amount) AS refunded
FROM payments p
JOIN refunds r ON r.payment_id = p.id
WHERE r.status = 'succeeded'
GROUP BY p.id, p.amount
HAVING SUM(r.amount) > p.amount;
Every row is an over-refund the old release accepted, and the request that caused it would be rejected on the new one. Trace each row back to the code path that issued the second refund. No rows means you have not over-refunded yet; it does not prove your code cannot.
Then search your code for amount_refunded. Every read is a place where the
meaning of the field changes when you upgrade.
Changed Behavior
Before 2026-09-01, only succeeded refunds counted against a payment. A
refund still processing was invisible to the check, so two concurrent ₹600
refunds against a ₹1,000 payment were both accepted, and the payer received
₹1,200 for a ₹1,000 payment.
From 2026-09-01, a refund in flight reserves its amount. A new refund is
accepted only if it fits in what is left, and the check and the reservation
are a single atomic step, so concurrent requests cannot both claim the same
headroom:
headroom = amount - amount_refunded - amount_refund_pending
The two fields on the right changed with it:
Payment.amount_refundednow counts onlysucceededrefunds: money that has actually been returned. It previously also counted refunds stillprocessing.Payment.amount_refund_pendingis new. It counts refunds stillprocessing, and it is returned only from this release, because on earlier releasesamount_refundedalready includes those refunds.
Concurrent refunds that fit within the payment are still accepted, as they were before. Only the requests that would over-refund are now rejected.
Update Your Integration
Update Headroom Calculations
Subtract the pending total as well. On the new release, amount_refunded
alone overstates what is available, because pending refunds are no longer in
it.
// Before
const headroom = payment.amount.amount - payment.amount_refunded.amount;
// After
const headroom =
payment.amount.amount -
payment.amount_refunded.amount -
payment.amount_refund_pending.amount;
Update Displays and Reconciliation
Show the two figures separately. A refund that is still processing belongs
under amount_refund_pending until it succeeds; reconcile against
Refund.settled_at rather than created_at, so a refund lands in the period
in which it actually completed.
Handle refund_amount_exceeded
On the new release, 422 refund_amount_exceeded means the refund does not fit
in the headroom left after everything already in flight. Before you write any
handling, find out why your code asked for it. The two usual causes are both
fixable at the source:
- A retry with a new key. Retries of one refund must reuse the
Idempotency-Keyof the first attempt. A new key makes the retry a second refund, and the second refund is what the API is now rejecting. - A remainder computed without the pending total. Fix the calculation as shown in Update Headroom Calculations.
Whatever the cause, do not retry the rejected request on a timer. It can only
succeed if a refund that is already pending ends failed and releases its
reservation. Retrying on a schedule either waits for that by accident or loops
forever.
try {
await ledger.refunds.create(paymentId, body, { idempotencyKey });
} catch (err) {
if (err.code !== "refund_amount_exceeded") throw err;
const payment = await ledger.payments.retrieve(paymentId);
if (payment.amount_refund_pending.amount > 0) {
// Fits only if an in-flight refund fails. Decide again when the
// refund.succeeded or refund.failed event for this payment arrives.
await holdForRefundEvent(paymentId, body);
} else {
// Nothing in flight: the request exceeds the payment outright.
await routeToOperations(paymentId, body);
}
}
holdForRefundEvent and routeToOperations stand in for your own queueing.
When the held refund's event arrives, read the payment again. If the pending
refund failed, the headroom is back and the held request may now fit. If it
succeeded, the held request is a genuine over-refund and belongs with
operations.
Verify the Change in Sandbox
The sandbox runs both releases, selected by the Ledger-Version header, and
holds every refund in processing for 30 seconds before it succeeds. That
window is long enough to reproduce the old defect on purpose.
- Create and finalize a ₹1,000 invoice, and pay it in full with a sandbox payment method that succeeds immediately.
- Issue a ₹600 refund with
Ledger-Version: 2026-09-01. - Within 30 seconds, issue a second ₹600 refund with the same header and a
new
Idempotency-Key. - Expected:
422 refund_amount_exceeded. Retrieving the payment showsamount_refundedat0andamount_refund_pendingat60000. - Repeat steps 1 to 3 on a new invoice, sending
Ledger-Version: 2026-06-15instead. - Expected:
201for both refunds. Once both succeed, the payment shows ₹1,200 refunded against ₹1,000 paid. This is the defect the release fixes.
If step 3 returns 201, the header is not reaching the API. Check it on the
outgoing request rather than in the client configuration; several SDK versions
accept a default version at construction time and drop it on per-call
overrides.
Timeline
| Date | What happens |
|---|---|
| 2026-09-01 | Release available. Accounts created from this date default to it. Existing accounts keep their pin and opt in with the header. |
| 2027-09-01 | Earliest date the old behavior is removed. Account owners get at least 90 days' notice by email before it is. |
Existing accounts have at least twelve months. The old behavior is not being removed abruptly, because the integrations most likely to depend on it are the ones least likely to be under active development.
Stay on the Previous Release
Nothing forces you to move before the removal date. Until then, keep sending
Ledger-Version: 2026-06-15, or send nothing if your account's default
predates 2026-09-01. Two cautions:
- Set the version in one place. The pin is per request. A client that
sends the new version on some calls and the old one on others reads
amount_refundedwith two different meanings. - The old release still over-refunds. Staying on it keeps the defect. Run the query from Check Your Own Data each month until you move, so an over-refund is found by you rather than by an auditor.
Get Help
Quote the request_id from the error body when you contact support. For a
rejected refund, include the payment ID as well; the payment's refund history
is the first thing support will look at.