Refunds
Refunds are a separate API call. The gateway supports partial and full refunds across PSPs that allow them.
Issuing a refund
Section titled “Issuing a refund”POST /v1/refundsX-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"}What you’ll see
Section titled “What you’ll see”A refund.succeeded outbound webhook fires once the PSP confirms. Same signing scheme as payment webhooks. Your handler should:
- Mark the matching payment as refunded (or partially refunded) in your DB.
- Email the buyer / fire your refund-confirmation flow.
- Be idempotent on
refund_id.
Per-PSP refund support
Section titled “Per-PSP refund support”| PSP | Refund support | Window | Notes |
|---|---|---|---|
| NICEPAY QRIS | ✓ | ~365 days off-us; 1 day on-us before manual | Gateway uses NICEPAY’s cancel API. |
| NICEPAY VA | ✓ | After buyer pays | Same NICEPAY cancel API. |
| Polar | ⚠️ Manual today | — | Driver 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 capture | Gateway uses PayPal’s capture-refund API. |
Status states
Section titled “Status states”| Status | Meaning |
|---|---|
initiated | Refund request sent to PSP, waiting for confirmation. |
succeeded | PSP confirmed the refund. Money is on its way back to the buyer. |
failed | PSP rejected the refund (rare; usually because the original capture is no longer refundable — too old, or the merchant balance is insufficient). |
Idempotency
Section titled “Idempotency”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.
Edge cases
Section titled “Edge cases”Refunding before the payment is succeeded
Section titled “Refunding before the payment is succeeded”422 — only succeeded payments can be refunded. If your buyer abandons checkout, the payment stays initiated and eventually expires; there’s nothing to refund.
Partial refunds
Section titled “Partial refunds”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.
Refunding a refund
Section titled “Refunding a refund”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.
Refund window expiry
Section titled “Refund window expiry”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.
Operator-initiated refunds
Section titled “Operator-initiated refunds”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.