Refunds integration guide
Audience. Merchants integrating refund issuance into their support ops, e-commerce backend, or marketplace settlement layer. Covers the 5-state lifecycle, the off-chain ledger / on-chain settlement split, the
stuckurgency rollup, and the BuildRefundOnchainTx flow.
TL;DR
- 5 statuses:
REQUESTED→PROCESSING→SUCCEEDED | FAILED | CANCELED. - Off-chain ledger first. v1 (Phase 4.2.α) records the refund on the merchant ledger; the on-chain refund ix lands when the router program upgrade ships.
refundTxSigisnulluntil the merchant submits the on-chain tx. - Idempotent. Pass
Idempotency-KeyonPOST /v1/refundsso customer-driven retries don’t double-issue. - Urgency signal.
GET /v1/refunds/count.stuckcounts REQUESTED|PROCESSING rows older than 24 hours — usually wallet-side, RPC-side, or never-submitted-onchain. Surface this as “needs attention” in any custom dashboard.
State machine
REQUESTED
│
├── process ──▶ PROCESSING
│ │
│ ├── mark-succeeded(refundTxSig?) ──▶ SUCCEEDED
│ ├── mark-failed(failureReason) ──▶ FAILED
│ └── cancel ──▶ CANCELED
│
├── mark-succeeded ──▶ SUCCEEDED (skipped processing — instant ledger refund)
├── mark-failed ──▶ FAILED (e.g. wallet pre-flight failure)
└── cancel ──▶ CANCELED (merchant withdraws before processing)The process transition is conventionally where the merchant kicks off the on-chain refund — sign the tx with the refund-delegate key, submit, then call mark-succeeded with the refundTxSig once confirmed. The two-step pattern lets the merchant handle pre-confirmation idempotency cleanly.
Resource shape
{
"id": "ref_2c4e8f1a9b3d456",
"paymentIntentId": "pi_abc123",
"customerId": "cust_456",
"status": "PROCESSING",
"amount": "12.500000",
"currency": "USDC",
"reason": "REQUESTED_BY_CUSTOMER",
"description": "Customer cancelled within return window",
"failureReason": null,
"refundTxSig": null,
"processedAt": "2026-05-03T00:00:30.000Z",
"succeededAt": null,
"failedAt": null,
"canceledAt": null,
"metadata": { "ticketId": "ZD-9842" },
"createdAt": "2026-05-03T00:00:00.000Z",
"updatedAt": "2026-05-03T00:00:30.000Z"
}amountis in the same smallest-unitDecimal(20,6)convention as PaymentIntent. Pass a value≤ paymentIntent.amount - amountAlreadyRefundedfor partial refunds.refundTxSigisnulluntil the merchant callsmark-succeededwith the on-chain signature. Off-chain ledger-only refunds land directly inSUCCEEDEDwithout a sig.failureReasonis captured onmark-failedand surfaces on the dashboard detail.
Refund.reason values
Industry-standard Refund.reason taxonomy:
| Reason | When |
|---|---|
DUPLICATE | Customer was charged twice; refund the duplicate |
FRAUDULENT | Card-not-present fraud or chargeback risk |
REQUESTED_BY_CUSTOMER | Standard support-driven refund |
OTHER | Catch-all when none of the above fit |
Endpoints
Create
POST /v1/refunds
Authorization: Bearer <JWT>
Idempotency-Key: refund_for_pi_abc123_v1
Content-Type: application/json
{
"paymentIntentId": "pi_abc123",
"amount": "12.500000",
"reason": "REQUESTED_BY_CUSTOMER",
"description": "Customer cancelled within return window",
"metadata": { "ticketId": "ZD-9842" }
}Returns the Refund row in REQUESTED. The merchant role gate requires OWNER / ADMIN / FINANCE. Idempotency key recommended — customer-driven retries are common.
Process (kick off the on-chain leg)
POST /v1/refunds/:id/processTransitions REQUESTED → PROCESSING and stamps processedAt. No body. The merchant is expected to submit the on-chain refund tx between this call and mark-succeeded — the two-step pattern lets you own the tx-confirmation polling.
Mark succeeded
POST /v1/refunds/:id/mark-succeeded
Content-Type: application/json
{ "refundTxSig": "5kQy…" }Optional body — pass the on-chain signature when there is one. Off-chain ledger-only refunds (e.g. pre-paid balance return) can omit. Transitions REQUESTED | PROCESSING → SUCCEEDED. Stamps succeededAt. Emits payment_intent.refunded webhook.
Mark failed
POST /v1/refunds/:id/mark-failed
Content-Type: application/json
{ "failureReason": "Insufficient funds in refund-delegate wallet" }Required reason. Transitions REQUESTED | PROCESSING → FAILED. Stamps failedAt.
Cancel
POST /v1/refunds/:id/cancelTransitions REQUESTED → CANCELED (only — once a refund is PROCESSING, the merchant must wait for the on-chain side to resolve via mark-succeeded or mark-failed).
List + filter
GET /v1/refunds
GET /v1/refunds?status=PROCESSING
GET /v1/refunds?customerId=cust_456&limit=50
GET /v1/refunds?paymentIntentId=pi_abc123Cursor-paginated newest-first. Filter by status (single value), customerId, paymentIntentId.
Counts (for dashboards)
GET /v1/refunds/countReturns:
{
"requested": 1,
"processing": 2,
"succeeded": 47,
"failed": 3,
"canceled": 1,
"total": 54,
"stuck": 1
}stuck (Phase 6.8.γ.24) counts REQUESTED|PROCESSING rows whose createdAt is more than 24 hours old. Always ≤ requested + processing. Surfaces a stuck-refund — usually the merchant’s refund-delegate wallet ran out, the RPC stalled, or mark-succeeded was never called.
Webhook events
Refunds emit two parallel event streams:
-
payment_intent.refunded— fires alongside the terminalmark-succeededtransition for the parent intent’s perspective. Useful for fulfillment pipelines that already handle the parent intent. -
Standard
refund.*events — every state-machine transition emits a dedicated event:refund.created—POST /v1/refundsacceptedrefund.updated—processtransition (REQUESTED → PROCESSING)refund.succeeded—mark-succeeded(terminal happy path)refund.failed—mark-failed(terminal failure)refund.canceled—canceltransition (terminal pre-processing)
Pick the stream that matches your integration model. Most fulfillment-driven merchants subscribe to payment_intent.refunded (one event per intent lifecycle); refund-tooling integrations subscribe to refund.succeeded (one event per refund row).
function handleRefundedWebhook(event: Event) {
if (event.type !== 'payment_intent.refunded') return;
const intent = event.data.object;
if (intent.amount_refunded === intent.amount) {
// Full refund — flip the order to REFUNDED in your store.
return store.refundOrder(intent.id);
}
// Partial refund — flip to PARTIALLY_REFUNDED with the new
// remaining-balance number.
return store.partiallyRefundOrder(intent.id, intent.amount_refunded);
}The intent’s status does not flip to REFUNDED — it stays at its terminal happy-path status (PAID/CLAIMED/etc) — because a partial refund leaves the original payment intact. Check amount_refunded to distinguish full vs partial.
Idempotency model
NinjaPay’s refunds API supports two layers of idempotency:
- Stripe-compatible
Idempotency-Keyheader onPOST /v1/refunds. A repeat call with the same key returns the original row (200) instead of creating a second refund. - Body-level
idempotencyKeyfield for callers that can’t easily set headers (e.g. forms POSTed from a workflow tool). Same dedupe semantics.
The header takes precedence when both are provided. Pick a stable key shape — refund_for_<paymentIntentId>_<reason> is a common pattern.
Sample flow — full refund with on-chain confirmation
import { NinjaPayClient } from '@ninjapay/sdk';
const client = new NinjaPayClient({ apiKey: process.env.NINJAPAY_API_KEY });
async function refundFully(paymentIntentId: string) {
// 1. Create the refund row (off-chain ledger entry).
const refund = await client.refunds.create(
{
paymentIntentId,
amount: '12.500000',
reason: 'REQUESTED_BY_CUSTOMER',
description: 'Customer cancelled within return window',
},
{ idempotencyKey: `refund_for_${paymentIntentId}_v1` },
);
// 2. Kick off the on-chain leg.
await client.refunds.process(refund.id);
// 3. Submit the on-chain refund tx with your refund-delegate
// keypair. (Once the router-program refund ix ships, the SDK
// will expose buildRefundOnchainTx() to skip this step.)
const txSig = await mySolanaClient.submitRefundTx({
paymentIntent: paymentIntentId,
amount: '12.500000',
payerWallet: refund.payerWallet,
});
await mySolanaClient.confirmTx(txSig);
// 4. Close the loop on NinjaPay.
await client.refunds.markSucceeded(refund.id, { refundTxSig: txSig });
// The payment_intent.refunded webhook fires here; your store
// flips the order to REFUNDED.
}Operational urgency
Like disputes, refunds have a built-in urgency signal worth surfacing:
stuckcount (/v1/refunds/count.stuck) — REQUESTED|PROCESSING > 24 h. The dashboard renders a warning-tone banner above the refunds list when this is non-zero.- Audit log. Every transition is recorded under
refund.{created, processing, succeeded, failed, canceled}. UseGET /v1/audit-logs?action=refund.*for forensic reconstruction. - Operator escalation. NinjaPay operators can triage stuck refunds via the (forthcoming)
/v1/admin/refundsendpoint. Merchants on enterprise tier can request manual unblocking.
Related
packages/billing-runtime/src/refund-service.ts— service layer, state-transition guards, idempotency.apps/dashboard/src/app/dashboard/refunds/page.tsx— merchant UI: list, status chips, stuck banner, URL-state filter.- Webhook integration guide —
payment_intent.refundedevent details + verification. - Disputes integration guide — for the dispute → refund resolution path (
close-as-refundedlinks the refund to the dispute).