D
P
0

Web Development

Refund Fails With 422 “amount is required” Though the Docs Say It's Optional — Always Send `amount.value`

June 28, 2026·4 min read
Refund Fails With 422 “amount is required” Though the Docs Say It's Optional — Always Send `amount.value`

I was wiring up a payment provider's refund API into a booking platform I built. The flow was simple: an admin hits a "Refund" button, and a booking's payment gets fully returned. The docs implied the amount field was optional — leave it out, they suggested, and the provider would refund the full payment automatically. So I took them at their word and sent the request without an amount.

It failed. Not with a friendly message, but with an HTTP 422 and a body like this:

{
  "status": 422,
  "title": "Unprocessable Entity",
  "detail": "amount is required for payments"
}

The triggering code looked roughly like this. I passed amount as null to mean "full refund," exactly the way I'd read the docs:

async function refundPayment(paymentId, amount = null) {
  const res = await fetch(`https://api.provider.test/v2/payments/${paymentId}/refunds`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
    },
    // amount intentionally null = "full refund" per the docs
    body: JSON.stringify({ amount }),
  });
 
  if (!res.ok) {
    throw new Error(`Refund failed: ${res.status}`);
  }
  return res.json();
}

Every press of the button produced the same 422. And the cruel irony: the field the docs called "optional" was exactly the one being rejected.

Why this happens

I stopped trusting the prose on the docs page and started reading the actual 4xx response body. The message was unambiguous: amount is required for payments. So for the transaction type I was using, amount wasn't optional at all — it was effectively required. The "optional for a full refund" line in the docs was half-true for some other case, but it didn't hold on the path I was taking.

This is a recurring pattern with payment APIs: the docs say a field is "optional," yet behind the scenes their validator rejects it when it's empty. The source of truth isn't a paragraph in the documentation — it's the 422 body the server sends back. Once I read that string literally, the root cause was obvious: I needed to send a concrete amount object, with its value and currency, not null.

The fix

The solution: always send amount explicitly as an { value, currency } object. To preserve the "full refund" idea, I made the wrapper smart — when the caller passes null, the wrapper defaults to the full booking/payment total. So from the caller's side a "full refund" stays terse, but what actually ships to the provider is always a real amount.value.

async function refundPayment(paymentId, { fullTotal, currency }, amount = null) {
  // The provider calls amount "optional," but its 422 says otherwise.
  // If the caller wants a full refund (null), send the full total explicitly.
  const refundAmount = amount ?? {
    value: fullTotal,   // e.g. "49.00" — decimal string, per the provider's format
    currency,           // e.g. "EUR"
  };
 
  const res = await fetch(`https://api.provider.test/v2/payments/${paymentId}/refunds`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ amount: refundAmount }),
  });
 
  if (!res.ok) {
    const body = await res.text();
    throw new Error(`Refund failed: ${res.status} ${body}`);
  }
  return res.json();
}

With amount.value always populated, the 422 vanished and full refunds went through cleanly.

But there was a second, separate problem. One time the refund call ran slow, and the UI hung — the button stuck on a spinner, and the admin had no idea whether the refund had gone through or not. I never want a slow network to hold the interface hostage. So I wrapped the request in a Promise.race against a timeout, and afterward I reconciled the booking state straight from the server:

function withTimeout(promise, ms) {
  const timeout = new Promise((_, reject) =>
    setTimeout(() => reject(new Error("timeout")), ms)
  );
  return Promise.race([promise, timeout]);
}
 
async function handleRefundClick(paymentId, booking) {
  try {
    await withTimeout(refundPayment(paymentId, booking, null), 8000);
  } catch (err) {
    // Slow or timed out isn't the same as failed — don't leave the spinner stuck.
  } finally {
    // Source of truth: ask the server what the booking state actually is.
    await reconcileBookingState(booking.id);
    setSpinner(false);
  }
}

Now, fast or slow, the UI never gets trapped. After the timeout I re-ask the server what the booking's real state is, and the UI follows that truth — instead of guessing from a response that might never arrive on time.

The takeaway

Two things I carry forward. First, payment API docs sometimes lie about "optional" fields. Don't trust the prose — read the actual 4xx response body, and send the field explicitly when the server demands it. The string amount is required for payments was more honest than any paragraph of documentation.

Second, treat every network call to a payment provider as fallible. Never assume it returns promptly. Wrap it in a timeout, and afterward reconcile state from the server as the source of truth. That way a slow network is only slightly slower — not a spinner stuck forever and an admin left guessing whether the client's money has actually been returned.