POST /admin/@refund_invoice refunds a Stripe or PayPal invoice in full.
Authenticate with Authorization: Bearer <admin-key>; the existing ?key=<admin-key> authentication also works. Unauthorized requests return 404.
Send JSON with the numeric primary key from accounts_invoices.id, not the public invoice UUID:
{ "invoiceId": 123 }
The API selects the gateway and payment reference from the invoice. It does not accept amount, reason, or payment-reference overrides. Only paid invoices can initiate a refund. Already-refunded invoices can reconcile a refund confirmed by the provider. Pending/failed invoices, unsupported gateways, missing payment references, and partial refunds cannot initiate a refund.
A completed refund returns HTTP 200:
{
"invoiceId": 123,
"gateway": "stripe",
"status": "refunded",
"refundId": "re_example"
}
If inspection finds a completed full refund, status is already_refunded and no new refund is submitted. refundId can be null when completion is known from the original transaction or several refunds together cover the payment.
A pending refund returns HTTP 202 with the same fields and status: "pending". This includes Stripe refunds awaiting action and PayPal delayed refunds. The invoice remains paid until completion is confirmed by a later request, Stripe webhook, or PayPal IPN.
Confirmed refunds use the existing invoice bookkeeping: cancel the account’s payment method/renewal, mark the invoice refunded, and recalculate paid access using the remaining pending/paid invoices. Refunding the latest invoice can end access immediately. If cancellation fails, the invoice remains paid so a later request can retry. Once the invoice is refunded, later requests and provider events do not cancel a replacement payment method.
Errors contain an error code:
| HTTP | Error | Meaning |
|---|---|---|
| 400 | invalid_invoice_id |
Missing or invalid positive integer ID. |
| 404 | unknown_invoice |
No matching invoice. |
| 409 | invoice_not_paid |
Invoice is neither paid nor refunded. |
| 409 | unsupported_gateway |
Gateway is not Stripe or PayPal. |
| 409 | missing_payment_reference |
No stored transaction reference. |
| 409 | payment_not_refundable |
Provider state does not permit this refund. |
| 409 | payment_partially_refunded |
A partial refund exists or is pending. |
| 409 | invoice_state_changed |
Invoice says refunded but the provider says refundable. |
| 409 | refund_failed |
Provider returned a failed/canceled refund object. Includes result fields. |
| 429 | daily_refund_limit_reached |
The daily submission allowance is exhausted. |
| 502 | refund_lookup_failed |
Provider inspection failed; nothing was submitted by this request. |
| 502 | refund_outcome_unknown |
Submission threw an error or returned an unrecognized result. Completion is not confirmed. |
| 500 | refund_bookkeeping_failed |
The provider confirmed the refund, but local bookkeeping failed. Includes result fields. |
The separate admin_refund_invoice Redis bucket allows 10 submission attempts per UTC day. Validation, inspections, reconciliation, and recognized pending refunds consume no allowance. Every submission attempt consumes allowance even if it fails; exhausted requests return 429 until UTC midnight. The existing cancellation allowance is independent.
Each payment has a stable provider idempotency identifier: Stripe uses admin-refund:<payment-intent>, and PayPal uses a 38-character MSGSUBID derived from the transaction reference. Stripe automatic network retries are disabled for this operation. Neither provider submission is automatically retried by this route.
After refund_outcome_unknown, a subsequent explicit request first inspects the provider. A confirmed refund is reconciled without another submission. If the payment is still refundable, submission uses the same identifier and consumes another attempt. Provider errors during submission are conservatively reported as an unknown outcome, including explicit API rejections without a refund object.
After refund_bookkeeping_failed, retry the same invoice to complete bookkeeping without issuing another refund. Do not create a new payment reference or idempotency identifier to recover an uncertain outcome.
Stripe’s existing fraud automation keeps its fraud-specific reason and idempotency identifier. No customer email, schema migration, or deployment is part of this endpoint.