How-to

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:

  1. You compute refund headroom yourself from amount_refunded, for example as amount - amount_refunded.
  2. You display or reconcile amount_refunded and expect it to include refunds that have been requested but have not finished.
  3. 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_refunded now counts only succeeded refunds: money that has actually been returned. It previously also counted refunds still processing.
  • Payment.amount_refund_pending is new. It counts refunds still processing, and it is returned only from this release, because on earlier releases amount_refunded already 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-Key of 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.

  1. Create and finalize a ₹1,000 invoice, and pay it in full with a sandbox payment method that succeeds immediately.
  2. Issue a ₹600 refund with Ledger-Version: 2026-09-01.
  3. Within 30 seconds, issue a second ₹600 refund with the same header and a new Idempotency-Key.
  4. Expected: 422 refund_amount_exceeded. Retrieving the payment shows amount_refunded at 0 and amount_refund_pending at 60000.
  5. Repeat steps 1 to 3 on a new invoice, sending Ledger-Version: 2026-06-15 instead.
  6. Expected: 201 for 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_refunded with 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.

← Back to Portfolio