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
dueBydeadline 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 ofdueBy. - Familiar shape: reasons follow the established
Dispute.reasontaxonomy used by card-network-derived APIs; the evidence blob is free-formJsonfor now (structured fields land when merchant UI shapes the schema). - Recommended posture: subscribe to
payment_intent.refundedand to dispute-related webhook events (queued for Phase 4.6.ΞΆ). TrackdueByserver-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"
}amountdefaults toPaymentIntent.amount. Pass a smaller value at create time for partial disputes (one line item out of several).dueByis advisory in v1; the auto-loss worker is opt-in per ADR-0015. Track it server-side regardless β the dashboarddue_soonrollup filters on it.evidenceis free-formJson. 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:
| Reason | When |
|---|---|
CREDIT_NOT_PROCESSED | Customer was promised a refund and didnβt get it |
DUPLICATE | Customer was charged twice |
FRAUDULENT | Card-not-present fraud / unauthorized use |
GENERAL | Catch-all when none of the others fit |
PRODUCT_NOT_RECEIVED | Goods or service never arrived |
PRODUCT_UNACCEPTABLE | Goods or service materially differed from description |
SUBSCRIPTION_CANCELED | Customer cancelled but was charged anyway |
UNRECOGNIZED | Customer 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_REVIEWCursor-paginated newest-first. Filter by status (single OR repeated for OR), customerId, paymentIntentId, before (ISO timestamp).
Counts (for dashboards)
GET /v1/disputes/countReturns:
{
"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:
| Event | Fires when |
|---|---|
charge.dispute.created | A new Dispute row is created (POST /v1/disputes). |
charge.dispute.updated | Evidence submitted (POST /:id/submit-evidence). |
charge.dispute.closed | Any terminal transition (adjudicate, close-as-warning, close-as-refunded). |
charge.dispute.funds_reinstated | Fires alongside closed when the outcome is WON or WARNING_CLOSED β the merchant keeps the funds. |
charge.dispute.funds_withdrawn | Fires 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:
due_sooncount (/v1/disputes/count.due_soon) β NEEDS_RESPONSE rows withdueBywithin 48 hours. The dashboard renders a destructive-tone banner above the dispute list when this is non-zero.- 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. - 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. UseGET /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 });
}Related
packages/billing-runtime/src/dispute-service.tsβ service layer, state-transition guards.apps/dashboard/src/app/dashboard/disputes/page.tsxβ merchant UI: list, filter, due-soon banner, drill-in.apps/dashboard/src/app/dashboard/admin/disputes/page.tsxβ operator UI: cross-org triage by org / status / due-window.- Webhook integration guide β for when the dedicated dispute event types ship.
- Quickstart β first NinjaPay charge end-to-end.