Skip to Content
Devnet open · mainnet by application — these docs ship with the pre-alpha release.

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 stuck urgency rollup, and the BuildRefundOnchainTx flow.

TL;DR

  • 5 statuses: REQUESTEDPROCESSINGSUCCEEDED | 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. refundTxSig is null until the merchant submits the on-chain tx.
  • Idempotent. Pass Idempotency-Key on POST /v1/refunds so customer-driven retries don’t double-issue.
  • Urgency signal. GET /v1/refunds/count.stuck counts 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" }
  • amount is in the same smallest-unit Decimal(20,6) convention as PaymentIntent. Pass a value ≤ paymentIntent.amount - amountAlreadyRefunded for partial refunds.
  • refundTxSig is null until the merchant calls mark-succeeded with the on-chain signature. Off-chain ledger-only refunds land directly in SUCCEEDED without a sig.
  • failureReason is captured on mark-failed and surfaces on the dashboard detail.

Refund.reason values

Industry-standard Refund.reason taxonomy:

ReasonWhen
DUPLICATECustomer was charged twice; refund the duplicate
FRAUDULENTCard-not-present fraud or chargeback risk
REQUESTED_BY_CUSTOMERStandard support-driven refund
OTHERCatch-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/process

Transitions 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/cancel

Transitions 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_abc123

Cursor-paginated newest-first. Filter by status (single value), customerId, paymentIntentId.

Counts (for dashboards)

GET /v1/refunds/count

Returns:

{ "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:

  1. payment_intent.refunded — fires alongside the terminal mark-succeeded transition for the parent intent’s perspective. Useful for fulfillment pipelines that already handle the parent intent.

  2. Standard refund.* events — every state-machine transition emits a dedicated event:

    • refund.createdPOST /v1/refunds accepted
    • refund.updatedprocess transition (REQUESTED → PROCESSING)
    • refund.succeededmark-succeeded (terminal happy path)
    • refund.failedmark-failed (terminal failure)
    • refund.canceledcancel transition (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:

  1. Stripe-compatible Idempotency-Key header on POST /v1/refunds. A repeat call with the same key returns the original row (200) instead of creating a second refund.
  2. Body-level idempotencyKey field 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:

  1. stuck count (/v1/refunds/count.stuck) — REQUESTED|PROCESSING > 24 h. The dashboard renders a warning-tone banner above the refunds list when this is non-zero.
  2. Audit log. Every transition is recorded under refund.{created, processing, succeeded, failed, canceled}. Use GET /v1/audit-logs?action=refund.* for forensic reconstruction.
  3. Operator escalation. NinjaPay operators can triage stuck refunds via the (forthcoming) /v1/admin/refunds endpoint. Merchants on enterprise tier can request manual unblocking.