Skip to Content
Devnet open Β· mainnet by application β€” these docs ship with the pre-alpha release.
IntegrationsDisputes

Disputes integration guide

Audience. Merchants integrating NinjaPay disputes into their support ops, fraud workflow, or marketplace adjudication surface. Covers the 6-state lifecycle, evidence submission, response-window urgency, and the webhook + dashboard signals that drive ops triage.

TL;DR

  • 6 statuses: NEEDS_RESPONSE β†’ UNDER_REVIEW β†’ WON | LOST | WARNING_CLOSED | CHARGE_REFUNDED.
  • Auto-loss: miss the dueBy deadline without submitting evidence and the dispute auto-resolves in the customer’s favour. NinjaPay does not automatically transition the row β€” the worker that does this is operator-configurable per ADR-0015 β€” but the dashboard surfaces a destructive-tone banner the moment any row is within 48 hours of dueBy.
  • Familiar shape: reasons follow the established Dispute.reason taxonomy used by card-network-derived APIs; the evidence blob is free-form Json for now (structured fields land when merchant UI shapes the schema).
  • Recommended posture: subscribe to payment_intent.refunded and to dispute-related webhook events (queued for Phase 4.6.ΞΆ). Track dueBy server-side and escalate ≀ 48 h.

State machine

NEEDS_RESPONSE β”‚ β”œβ”€β”€ submit-evidence ──▢ UNDER_REVIEW β”‚ β”‚ β”‚ β”œβ”€β”€ adjudicate(outcome=won) ──▢ WON β”‚ β”œβ”€β”€ adjudicate(outcome=lost) ──▢ LOST β”‚ β”œβ”€β”€ close-as-warning ──▢ WARNING_CLOSED β”‚ └── close-as-refunded ──▢ CHARGE_REFUNDED β”‚ β”œβ”€β”€ close-as-warning ──▢ WARNING_CLOSED (skip evidence β€” small-amount waiver) β”œβ”€β”€ close-as-refunded ──▢ CHARGE_REFUNDED (issue Refund first) └── (deadline elapses) ─▢ effectively LOST (auto-transition is opt-in per ADR-0015)

reason is captured at creation and never changes. outcomeReason is captured on every terminal transition and surfaces on the audit log + dashboard detail.

Resource shape

Dispute (merchant wire format)

{ "id": "dp_2c4e8f1a9b3d456", "paymentIntentId": "pi_abc123", "customerId": "cust_456", "status": "NEEDS_RESPONSE", "reason": "FRAUDULENT", "amount": "12.500000", "currency": "USDC", "dueBy": "2026-05-10T00:00:00.000Z", "evidence": {}, "outcomeReason": null, "relatedRefundId": null, "evidenceSubmittedAt": null, "adjudicatedAt": null, "createdAt": "2026-05-03T00:00:00.000Z", "updatedAt": "2026-05-03T00:00:00.000Z" }
  • amount defaults to PaymentIntent.amount. Pass a smaller value at create time for partial disputes (one line item out of several).
  • dueBy is advisory in v1; the auto-loss worker is opt-in per ADR-0015. Track it server-side regardless β€” the dashboard due_soon rollup filters on it.
  • evidence is free-form Json. Submit a structured blob with whatever the customer’s complaint requires (receipt URLs, shipping tracking, customer-comm transcripts).

Dispute.reason values

Industry-standard reason codes:

ReasonWhen
CREDIT_NOT_PROCESSEDCustomer was promised a refund and didn’t get it
DUPLICATECustomer was charged twice
FRAUDULENTCard-not-present fraud / unauthorized use
GENERALCatch-all when none of the others fit
PRODUCT_NOT_RECEIVEDGoods or service never arrived
PRODUCT_UNACCEPTABLEGoods or service materially differed from description
SUBSCRIPTION_CANCELEDCustomer cancelled but was charged anyway
UNRECOGNIZEDCustomer doesn’t recall the charge (often early-stage fraud)

Endpoints

Create

POST /v1/disputes Authorization: Bearer <JWT> Idempotency-Key: dispute_for_pi_abc123 Content-Type: application/json { "paymentIntentId": "pi_abc123", "reason": "FRAUDULENT", "amount": "12.500000", "dueBy": "2026-05-10T00:00:00.000Z", "evidence": { "customerEmail": "alice@example.com", "ticketId": "ZD-9842" }, "metadata": { "internal_priority": "P1" } }

Returns the Dispute row. The merchant role gate requires OWNER / ADMIN / FINANCE. In production this endpoint is typically called by an internal support tool or marketplace adjudication surface, not the customer.

List + filter

GET /v1/disputes GET /v1/disputes?status=NEEDS_RESPONSE&status=UNDER_REVIEW

Cursor-paginated newest-first. Filter by status (single OR repeated for OR), customerId, paymentIntentId, before (ISO timestamp).

Counts (for dashboards)

GET /v1/disputes/count

Returns:

{ "needs_response": 4, "under_review": 0, "won": 12, "lost": 1, "warning_closed": 0, "charge_refunded": 2, "total": 19, "due_soon": 2 }

due_soon counts NEEDS_RESPONSE rows whose dueBy is within the next 48 hours. Always ≀ needs_response. Render this prominently in any custom dashboard β€” merchants who miss the window auto-lose the dispute.

Submit evidence

POST /v1/disputes/:id/submit-evidence Authorization: Bearer <JWT> Content-Type: application/json { "evidence": { "shipping_tracking_number": "1Z9999W99999999999", "shipping_carrier": "UPS", "shipping_address": "123 Main St, Springfield, OH", "delivery_proof_url": "https://files.example.com/proof-12345.pdf", "customer_communication_url": "https://files.example.com/email-thread.pdf", "refund_policy_url": "https://example.com/refunds", "duplicate_charge_id": null, "uncategorized_text": "Customer signed for delivery on 2026-05-02 14:32 UTC." } }

Transitions NEEDS_RESPONSE β†’ UNDER_REVIEW and stamps evidenceSubmittedAt. The evidence blob is opaque on the API side β€” structure it however your adjudication tooling expects. A future slice will land a typed evidence schema.

Adjudicate

POST /v1/disputes/:id/adjudicate Content-Type: application/json { "outcome": "won", "outcomeReason": "Delivery confirmed via UPS tracking + signature." }

Transitions UNDER_REVIEW β†’ WON | LOST depending on outcome. Idempotent against re-call with the same outcome (no-op + 200).

Close as warning

For small-amount disputes the merchant elects to absorb without contesting. Transitions NEEDS_RESPONSE | UNDER_REVIEW β†’ WARNING_CLOSED.

POST /v1/disputes/:id/close-as-warning { "outcomeReason": "Below-threshold dispute; absorbed without contest." }

Close as refunded

After issuing a Refund to resolve the customer complaint, link the refund to the dispute and close.

POST /v1/disputes/:id/close-as-refunded { "relatedRefundId": "ref_xyz", "outcomeReason": "Full refund issued; product recall." }

Transitions to CHARGE_REFUNDED. The relatedRefundId populates the dispute detail page so support staff can trace the resolution path.

Lifecycle event signals

Disputes emit standard charge.dispute.* webhook events on every transition. Subscribe via the dashboard or POST /v1/webhooks with one or more of:

EventFires when
charge.dispute.createdA new Dispute row is created (POST /v1/disputes).
charge.dispute.updatedEvidence submitted (POST /:id/submit-evidence).
charge.dispute.closedAny terminal transition (adjudicate, close-as-warning, close-as-refunded).
charge.dispute.funds_reinstatedFires alongside closed when the outcome is WON or WARNING_CLOSED β€” the merchant keeps the funds.
charge.dispute.funds_withdrawnFires alongside closed when the outcome is LOST or CHARGE_REFUNDED β€” the merchant loses the funds (a new Refund row is auto-emitted alongside in the CHARGE_REFUNDED case).

A single transition can emit two events (e.g. an adjudicate(outcome=won) call fires both funds_reinstated and closed). Build your handler to be order-agnostic β€” webhook delivery is per-subscription, not strictly ordered per-event.

For the count + urgency angle, you can also poll /v1/disputes/count on every dashboard render (sub-millisecond Prisma groupBy) to drive the due_soon signal even if you’re not subscribed to the dispute events themselves.

Operational urgency

The dashboard surfaces three signals that you should mirror in any custom integration:

  1. due_soon count (/v1/disputes/count.due_soon) β€” NEEDS_RESPONSE rows with dueBy within 48 hours. The dashboard renders a destructive-tone banner above the dispute list when this is non-zero.
  2. Operator triage. NinjaPay operators can call GET /v1/admin/disputes?due_before=<iso> to surface every dispute about to auto-lose across orgs. Merchants on enterprise tier can request operator outreach at the 24-hour mark.
  3. Audit log. Every transition is recorded in the per-org audit log under actions dispute.created, dispute.evidence_submitted, dispute.adjudicated, dispute.closed_as_warning, dispute.closed_as_refunded. Use GET /v1/audit-logs?action=dispute.* for a forensic record.

Sample handler β€” auto-respond with stored evidence

If your fraud-tooling has already gathered evidence by the time the dispute lands, automate submission:

import { NinjaPayClient } from '@ninjapay/sdk'; const client = new NinjaPayClient({ apiKey: process.env.NINJAPAY_API_KEY }); async function autoRespond(disputeId: string, paymentIntentId: string) { // Pull whatever your fraud system has on this charge. const evidence = await fraudSystem.gatherEvidence(paymentIntentId); if (evidence === null) { // No automated evidence β€” fall through to manual queue. await pagerDuty.notify({ severity: 'warning', summary: `Dispute ${disputeId} requires manual evidence`, }); return; } await client.disputes.submitEvidence(disputeId, { evidence }); }