Skip to content

Refunds

Refunds are a separate API call. The gateway supports partial and full refunds across PSPs that allow them.

POST /v1/refunds
X-PAY-Key: pk_<your app's public key>
X-PAY-Timestamp: <unix seconds>
X-PAY-Signature: <hex hmac>
Idempotency-Key: <unique uuid>
Content-Type: application/json
{
"payment_id": "abc123…",
"amount_minor": 1500,
"reason": "customer requested"
}

amount_minor is optional — omit for a full refund. Pass an explicit amount for partial refunds. Only one refund per payment is allowed today (no chained partial refunds).

Response:

{
"refund_id": "def456…",
"status": "succeeded",
"amount_minor": 1500,
"currency": "USD"
}

A refund.succeeded outbound webhook fires once the PSP confirms. Same signing scheme as payment webhooks. Your handler should:

  1. Mark the matching payment as refunded (or partially refunded) in your DB.
  2. Email the buyer / fire your refund-confirmation flow.
  3. Be idempotent on refund_id.
PSPRefund supportWindowNotes
NICEPAY QRIS~365 days off-us; 1 day on-us before manualGateway uses NICEPAY’s cancel API.
NICEPAY VAAfter buyer paysSame NICEPAY cancel API.
Polar⚠️ Manual todayDriver doesn’t yet implement programmatic refunds. Operator processes the refund in Polar’s dashboard; the gateway picks up the resulting order.refunded event from Polar and forwards refund.succeeded to your product. So the experience for your product is the same — you still get the webhook — but the trigger is operator-side.
PayPal~180 days from captureGateway uses PayPal’s capture-refund API.
StatusMeaning
initiatedRefund request sent to PSP, waiting for confirmation.
succeededPSP confirmed the refund. Money is on its way back to the buyer.
failedPSP rejected the refund (rare; usually because the original capture is no longer refundable — too old, or the merchant balance is insufficient).

Always include an Idempotency-Key on refund requests. Same rules as POST /v1/payments — see Idempotency. Refunds are particularly sensitive to retry-without-key bugs because a duplicate request creates a duplicate refund attempt at the PSP, which can result in the buyer being credited twice.

422 — only succeeded payments can be refunded. If your buyer abandons checkout, the payment stays initiated and eventually expires; there’s nothing to refund.

Pass amount_minor smaller than the original payment’s amount. Only ONE partial refund per payment today — calling POST /v1/refunds a second time on the same payment returns 422 payment already has a refund.

If you need to refund the remaining balance, ask the operator — they can do it manually at the PSP and the resulting webhook will surface as a refund.succeeded. We’ll wire chained partial refunds when a real product needs them.

Not supported. If you accidentally refund and need to charge the buyer again, issue a fresh POST /v1/payments for the desired amount. Each charge / refund is its own row.

PSP-specific. Once you’re past the window:

  • NICEPAY QRIS off-us → manual NICEPAY ops involvement.
  • Polar → manual Polar dashboard action; if past Polar’s window, you’re issuing the refund out-of-band (bank transfer / gift card / whatever).
  • PayPal → past 180 days, PayPal disallows refunds via API. Manual buyer-to-buyer transfer or PayPal-side dispute resolution.

Operators can refund any payment from the admin UI without going through your product’s API. The refund.succeeded webhook still fires to your webhook_url so your DB stays in sync.

This is useful for support cases where the buyer emails ops directly or where the product UI doesn’t expose a refund button.