# Issue refunds safely with idempotency

> Refund payments from your code without ever refunding twice: a stored Idempotency-Key, retries that are safe, refunds waiting for approval, and events.

Source: https://anytime-capital.com/docs/refund-safely · Markdown: https://anytime-capital.com/docs/refund-safely.md · All docs: https://anytime-capital.com/docs/llms-full.txt

A refund moves money back to a customer, so it must happen exactly once even when networks fail. The pattern: decide the refund and store its Idempotency-Key first, send it with that key as many times as you need, and read the outcome. The key also keys the refund at the payments network, so even a retry after a lost response refunds once. Endpoint details are on [Refunds](https://anytime-capital.com/docs/refunds.md).

## What can come back

| Answer | Meaning | Do |
| --- | --- | --- |
| `201`, `status: succeeded` | Refunded. | Done. |
| `201`, `status: pending` | Sent, not settled yet. | Wait for `refund.succeeded` or `refund.failed`. |
| `202`, `status: pending_approval` | Over your approval limit: an owner decides. Nothing refunded yet. | Keep the `re_appr_…` id; check it later or wait for `refund.created`. |
| `409 REFUND_PENDING_APPROVAL` | Another refund on this payment already waits for approval. | Don't send another; wait. |
| `409 NOT_REFUNDABLE`, `400 INVALID_INPUT` | Can't be refunded, or more than is left. | Show a person; don't retry unchanged. |
| `429`, `502`, `503`, `500 INTERNAL_ERROR`, no answer | Not done, or not known. | Retry with the **same** key. |
| `500 REFUND_NOT_RECORDED` | Sent, but our record isn't there yet. | Never resend: list `GET /api/v1/refunds?paymentId=…` in a minute. |

## Node.js

**refunds.mjs**

```javascript
// refunds.mjs — Node.js 18+. Run: node refunds.mjs pay_… 2500
import { randomUUID } from "node:crypto";

const API = "https://anytime-capital.com/api/v1";
const headers = { Authorization: `Bearer ${process.env.ANYTIME_API_KEY}` };

// 1. Decide the refund once, and give it a key you store with it (a table row in real life).
export function planRefund(paymentId, amountCents) {
  return { paymentId, amountCents, idempotencyKey: `refund-${randomUUID()}`, state: "planned", refundId: null };
}

// 2. Send it. Safe to call again and again with the same plan: the key makes it happen once.
export async function sendRefund(plan) {
  for (let attempt = 1; attempt <= 4; attempt++) {
    let res;
    try {
      res = await fetch(`${API}/refunds`, {
        method: "POST",
        headers: { ...headers, "Content-Type": "application/json", "Idempotency-Key": plan.idempotencyKey },
        body: JSON.stringify({ paymentId: plan.paymentId, amountCents: plan.amountCents, reason: "requested_by_customer" }),
      });
    } catch {
      await sleep(2 ** attempt); // the network failed: same key, try again
      continue;
    }
    const body = await res.json().catch(() => ({}));

    if (res.status === 201) return { ...plan, state: body.status, refundId: body.id }; // succeeded or pending
    if (res.status === 202) return { ...plan, state: "pending_approval", refundId: body.id }; // re_appr_…: an owner decides

    const code = body.error?.code;
    if (code === "REFUND_NOT_RECORDED") return { ...plan, state: "sent_unrecorded" }; // it went out: look it up, never resend
    if (res.status === 429 || res.status >= 500) {
      await sleep(Number(res.headers.get("Retry-After") ?? 2 ** attempt));
      continue; // RATE_LIMITED, PROCESSOR_ERROR, NOT_CONFIGURED, INTERNAL_ERROR: same key again
    }
    // NOT_FOUND, NOT_REFUNDABLE, REFUND_PENDING_APPROVAL, INVALID_INPUT…: a person needs to look.
    return { ...plan, state: "failed", error: `${code}: ${body.error?.message}` };
  }
  return { ...plan, state: "retry_later" }; // run sendRefund(plan) again later with the same plan
}

// 3. Later (or on a webhook): where does a refund that waited for approval stand?
export async function checkRefund(refundId) {
  const res = await fetch(`${API}/refunds/${refundId}`, { headers });
  const refund = await res.json();
  if (!res.ok) throw new Error(`${refund.error.code}: ${refund.error.message}`);
  if (refund.status === "failed" && refund.failureReason === "approval_declined") return "declined";
  if (refund.refundId) return `approved as ${refund.refundId}`;
  return refund.status;
}

const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));

if (process.argv[2]) {
  const plan = planRefund(process.argv[2], process.argv[3] ? Number(process.argv[3]) : undefined);
  console.log(await sendRefund(plan));
}
```

## Python

**refunds.py**

```python
# refunds.py — pip install requests. Run: python refunds.py pay_… 2500
import os
import sys
import time
import uuid

import requests

API = "https://anytime-capital.com/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['ANYTIME_API_KEY']}"}


def plan_refund(payment_id, amount_cents=None):
    """Decide the refund once and store its key with it."""
    return {"payment_id": payment_id, "amount_cents": amount_cents, "key": f"refund-{uuid.uuid4()}", "state": "planned"}


def send_refund(plan):
    """Safe to call again with the same plan: the key makes the refund happen once."""
    body = {"paymentId": plan["payment_id"], "reason": "requested_by_customer"}
    if plan["amount_cents"] is not None:
        body["amountCents"] = plan["amount_cents"]
    for attempt in range(1, 5):
        try:
            res = requests.post(f"{API}/refunds", headers={**HEADERS, "Idempotency-Key": plan["key"]}, json=body, timeout=30)
        except requests.RequestException:
            time.sleep(2**attempt)  # the network failed: same key, try again
            continue
        try:
            data = res.json()
        except ValueError:
            data = {}
        if res.status_code == 201:
            return {**plan, "state": data["status"], "refund_id": data["id"]}
        if res.status_code == 202:
            return {**plan, "state": "pending_approval", "refund_id": data["id"]}
        code = data.get("error", {}).get("code")
        if code == "REFUND_NOT_RECORDED":
            return {**plan, "state": "sent_unrecorded"}  # it went out: look it up, never resend
        if res.status_code == 429 or res.status_code >= 500:
            time.sleep(int(res.headers.get("Retry-After", 2**attempt)))
            continue
        return {**plan, "state": "failed", "error": f"{code}: {data.get('error', {}).get('message')}"}
    return {**plan, "state": "retry_later"}


if __name__ == "__main__":
    amount = int(sys.argv[2]) if len(sys.argv) > 2 else None
    print(send_refund(plan_refund(sys.argv[1], amount)))
```

## Partial refunds

- Send `amountCents` for part of a payment; leave it out to refund everything still left.
- Several partial refunds are fine, each with its own key, up to what was paid. Over that answers `400 INVALID_INPUT` naming `amountCents`.
- On `payment.refunded`, `data.amountRefundedCents` is the total refunded so far, not this refund alone.

## Keep a person in the loop

- Set a refund approval limit in **Settings → Team & approvals**: refunds over it by admins, API keys and AI agents wait for an owner. A limit of $0 makes every one of them wait.
- Give the code that refunds its own [restricted key](https://anytime-capital.com/docs/ai-agent-safety.md) with `refunds` write and nothing it doesn't need.
- Every refund is in your activity log under the key's name.

> **Note:** Stablecoin payments (`pay_sc…`) are refunded from your own wallet, not with this endpoint. See [Stablecoin checkout](https://anytime-capital.com/docs/stablecoin-checkout.md#refunds).

---

Anytime Capital is a fintech company, not a bank. Payment processing, banking and payout services are provided by Anytime Capital's licensed partners. Rates shown are for US card payments; international cards, currency conversion and disputes may carry additional fees shown in your dashboard before you accept a payment.
