Two kinds of dispute
A dispute is a claim that a payment should not stand. There are two kinds; they share an object and a hold, and nothing else.
| source | Who opens it | Who decides |
|---|---|---|
| CUSTOMER | The buyer, from the link on their receipt — or you, through the API, for a buyer who wrote to you. | You: accept to refund, reject to keep the sale. A NyukiAI operator can decide in your place if a dispute is left unanswered. |
| PROVIDER | The payment network, as a chargeback. NyukiAI records it. | Nobody here. The network has already taken the money; the outcome is recorded when it is known. |
- The buyer opens the dispute from disputeUrl, or you open it for them with POST /v1/disputes.
- The disputed amount is held, the dispute is NEEDS_RESPONSE, dispute.created is sent, and your team is told in the dashboard and by email.
- You accept or reject it — through the API, or on the Disputes page of the dashboard.
- Accepting sends a refund, and refund.succeeded or refund.failed says how it ended.
Give the buyer the link
Every charge that took money carries a disputeUrl: on the charges returned by GET /v1/payment_intents/:id and by confirm, on the charge.succeeded webhook, and on every dispute. It is the page a buyer opens to report a problem. Put it on your receipt and on the order page.
// data.object of charge.succeeded. The charges on
// GET /v1/payment_intents/:id carry the same disputeUrl.
{
"id": "ch_01JAY2K5RQ9M3W7ZB8XF4TC6VD",
"object": "charge",
"paymentIntent": "pi_01JAY2K5RQ9M3W7ZB8XF4TC6VD",
"status": "SUCCEEDED",
"amount": "500000",
"amountCaptured": "500000",
"currency": "XAF",
"seller": null,
"commission": "0",
"disputeUrl": "https://pay.nyukiai.com/dispute/ch_01JAY2K5RQ9M3W7ZB8XF4TC6VD.k3VnQ8…",
…
}The buyer’s page
disputeUrl opens a NyukiAI page under your app’s name and branding. The buyer needs no account and no password.
- It shows the payment — the seller’s name, the amount, the date, the description and the last four digits of the number that paid — whether a dispute can still be opened, and the buyer’s own dispute with its decision and refund status. It never shows internal ids, the fee, who decided, or the buyer’s own contact details, because receipts get forwarded.
- To open one, the buyer gives their name, a reason, a statement of at least ten characters, and an email address or a phone number — without either there is nobody to tell the outcome to.
- The page allows one dispute per payment, ever, so a buyer who was refused cannot freeze the same sale again. You can still open another through the API.
- The link never expires; the dispute window closes. If an old link stops opening, read a fresh disputeUrl off the charge, or open the dispute for the buyer through the API.
Open one for a buyer
For a buyer who wrote to your support instead of using the link. Name the payment with charge or paymentIntent (never both), give the buyer’s name and their statement, and optionally an amount, a reason and the locale to write to them in. It ends in the same object as a dispute opened from the page.
{
"id": "dp_01JAY2K5RQ9M3W7ZB8XF4TC6VD",
"object": "dispute",
"status": "NEEDS_RESPONSE",
"source": "CUSTOMER",
"reason": "PRODUCT_NOT_RECEIVED",
"amount": "500000",
"currency": "XAF",
"charge": "ch_01JAY2K5RQ9M3W7ZB8XF4TC6VD",
"seller": null,
"customer": {
"name": "Ada Ngo",
"email": "ada@example.com",
"phone": "+237677000000"
},
"statement": "The order never arrived.",
"decisionNote": null,
"decidedBy": null,
"refund": null,
"disputeUrl": "https://pay.nyukiai.com/dispute/ch_01JAY2K5RQ9M3W7ZB8XF4TC6VD.k3VnQ8…",
"evidenceDueBy": null,
"resolvedAt": null,
"created": 1785869000000
}The money is held while it is open
From the moment a dispute opens until it is decided, the disputed amount moves from available to held in the wallet the sale landed in — yours, or the seller’s. The charge shows DISPUTED. A decision releases the hold either way.
Accept: refund the buyer
POST /v1/disputes/:id/accept releases the hold and requests a refund of the disputed amount, back to the number that paid. The dispute is ACCEPTED at once. Whether the buyer has been paid is a separate fact: read refund.status. If the buyer left an email address, they are told the refund is on its way.
{
"id": "dp_01JAY2K5RQ9M3W7ZB8XF4TC6VD",
"object": "dispute",
"status": "ACCEPTED", // decided — NOT "the buyer has been paid"
"source": "CUSTOMER",
"decisionNote": "Confirmed with the courier. Refunding in full.",
"decidedBy": "api_key",
"refund": {
"id": "re_01JAY6F3N8Q2X7KT4MB5RC9VWD",
"status": "PENDING", // read this for whether money has moved
"failureCode": null,
"failureMessage": null
},
"resolvedAt": 1785872600000,
…
}- Mobile money has no reversal. A refund through Y-Note — MTN MoMo and Orange Money — is a new transfer back to the number that paid, for exactly the refunded amount. Partial refunds work the same way.
- It needs the payer’s number, taken from the payment’s providerParams.msisdn or the saved customer’s phone; without one the refund fails as INVALID_ACCOUNT. A number that has since moved to the other network is refused, because that would be a transfer to somebody else.
- It is paid from the float of the Y-Note account that collected the payment, with Y-Note’s transfer commission on top. On NyukiAI’s account that float is NyukiAI’s; on your own Y-Note account it is yours, and a refund fails when the float cannot cover it.
- It is rarely instant. NyukiAI asks Y-Note about it every minute until it settles, and nothing leaves your balance until the provider confirms.
- If the refund cannot be requested at all — the provider cannot refund (501), or the money has already gone back (409) — the whole decision is rolled back and the dispute stays open.
Reject: keep the sale
POST /v1/disputes/:id/reject releases the hold and keeps the sale; nothing goes back to the buyer. The note you send is shown to them with the decision, so say why.
When the refund fails
If the provider refuses the refund, the dispute stays ACCEPTED, the hold is already released, and the buyer has not been paid. refund.failed is sent, and the dispute carries the refund’s status and failure code. Nothing retries on its own — a provider that refused once usually refuses again until somebody fixes what it refused over. Fix that, then accept the dispute again: a new refund replaces the failed one.
Dispute statuses
The status list is shared with chargebacks, which is why a buyer dispute’s outcomes read oddly. Read status and refund.status together.
| status | On a buyer dispute | Decidable |
|---|---|---|
| NEEDS_RESPONSE | Open and waiting on you. The amount is held. | Yes |
| UNDER_REVIEW | A chargeback the app has answered with evidence. It waits for the provider's outcome. A buyer dispute never reaches it. | Yes |
| ACCEPTED | You agreed and a refund was requested. It says nothing about whether the buyer has been paid — read refund.status. Accept again only after a FAILED refund. | No |
| WON | You rejected it and kept the sale. Named for your side, as on a chargeback. | No |
| LOST | A chargeback outcome only: the network kept the money. | No |
| WITHDRAWN | A chargeback outcome only: the claim was dropped and the hold released. | No |
Webhooks
All five are written in the same transaction as the change they describe. dispute.created fires for chargebacks too, so check source. A dispute payload carries the charge, the seller, the source, the buyer’s contact details — you are the one who has to answer them — the statement, the decision note, the refund’s id and status, and dispute_url.
| type | When it fires |
|---|---|
| dispute.created | A dispute was opened — by a buyer (source CUSTOMER) or as a chargeback (PROVIDER). The disputed amount is held. |
| dispute.accepted | You accepted a buyer’s dispute: the hold is released and a refund requested. The buyer has not been paid yet. |
| dispute.rejected | You rejected a buyer’s dispute: the hold is released and you keep the sale. Stored as WON. |
| refund.succeeded | The provider confirmed the refund: the money is back with the payer and the reversal is booked. |
| refund.failed | The provider refused the refund. Nothing was booked and nothing retries on its own. |
{
"id": "wd_01JAY7E8M3Q5XT2KN9RB4VC6WD",
"object": "event",
"type": "dispute.accepted",
"created": 1785872600,
"environment": "LIVE",
"data": {
"object": {
"id": "dp_01JAY2K5RQ9M3W7ZB8XF4TC6VD",
"object": "dispute",
"charge": "ch_01JAY2K5RQ9M3W7ZB8XF4TC6VD",
"seller": null,
"source": "CUSTOMER",
"amount": "500000",
"currency": "XAF",
"reason": "PRODUCT_NOT_RECEIVED",
"status": "ACCEPTED",
"customer": {
"name": "Ada Ngo",
"email": "ada@example.com",
"phone": "+237677000000"
},
"statement": "The order never arrived.",
"decision_note": "Confirmed with the courier. Refunding in full.",
"refund": "re_01JAY6F3N8Q2X7KT4MB5RC9VWD",
"refund_status": "PENDING",
"dispute_url": "https://pay.nyukiai.com/dispute/ch_01JAY2K5RQ9M3W7ZB8XF4TC6VD.k3VnQ8…",
"evidence_due_by": null,
"resolved_at": 1785872600000,
"created": 1785869000000
}
}
}Refund directly
POST /v1/refunds returns money to the payer without a dispute, in full or in part. Name the payment with charge or paymentIntent, never both, and leave amount out to refund everything still refundable. The refund is PENDING until the provider confirms the money moved, and nothing leaves your balance before then: listen for refund.succeeded and refund.failed, or read it with GET /v1/refunds/:id.
{
"id": "re_01JAY6G7K3N9Q2XT5MB8RC4VWD",
"object": "refund",
"status": "PENDING",
"amount": "200000",
"currency": "XAF",
"reason": "REQUESTED_BY_CUSTOMER",
"description": "One item out of stock",
"charge": "ch_01JAY2K5RQ9M3W7ZB8XF4TC6VD",
"feeRefunded": "0",
"failureCode": null,
"failureMessage": null,
"succeededAt": null,
"created": 1785872600000
}The dispute window
A buyer can open a dispute for 30 days after the payment was captured. After that the page says the window has closed, and POST /v1/disputes answers 409. The link itself never expires; only the window does.
Chargebacks
A chargeback is a dispute with source PROVIDER: the payment network took the money back. NyukiAI records it, the amount is held in the same way, and it appears in GET /v1/disputes and as dispute.created. It is not yours to accept or reject — both calls answer 409.
What is not there yet
Known gaps, so you can plan around them:
- Evidence is not forwarded. On a chargeback you can attach an account and up to ten files from the dashboard; NyukiAI keeps them and takes them to the provider, which has no endpoint to receive them directly.
- Reminders or a deadline on a buyer dispute. It stays open, and the money held, until somebody decides it.
- Holding only what the wallet received. The hold takes the full disputed amount from available, which can go below zero.
- Blocking a direct refund while a buyer dispute is open — see the warning under direct refunds.
- Holds and reversals on your own Y-Note keys. There a dispute is recorded, decided and announced but holds nothing, and its refund goes out through your account and your float.
