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.
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 — 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 — 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
amountCentsfor 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_INPUTnamingamountCents. - On
payment.refunded,data.amountRefundedCentsis 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 with
refundswrite and nothing it doesn't need. - Every refund is in your activity log under the key's name.