When a cardholder asks their bank to reverse a card payment, the bank opens a case against your sale. Fluveo records that case as a Dispute (du_...), takes the disputed amount out of your balance while it runs, and tells your webhook endpoint about every step. From there you have two moves: prove the sale was good, and let the bank rule; or accept the case, and the amount stays with the buyer.
Only card payments can be disputed. Every store reads, answers and closes its disputes with the same /v1/disputes calls shown on this page.
From the bank's case to the final ruling#
- The case opens. A Dispute appears on the charge and
charge.dispute.createdreaches your endpoint. - When the amount comes out of your balance,
charge.dispute.funds_withdrawnfollows. - A new chargeback has
statusneeds_response. A question the bank asks before any chargeback (an inquiry) haswarning_needs_response. Either way,evidence_details.due_byholds your deadline as a Unix timestamp. - Before that deadline, submit evidence or close the dispute to accept it. Submitted evidence moves the dispute to
under_reviewand sendscharge.dispute.updated. - The ruling arrives as
charge.dispute.closed, withstatusset towonorlost(warning_closedwhen an inquiry ends). A win that returns the money also sendscharge.dispute.funds_reinstated.
Losing a dispute can push your balance below zero. Payouts then wait until new sales cover the gap; see Balance and payouts.
Dispute statuses#
| Status | Meaning |
|---|---|
warning_needs_response | The bank wants details before it files any chargeback. A good answer can keep it from filing one. |
warning_under_review | Your answer to the inquiry is with the bank. |
warning_closed | The inquiry is over and no chargeback came of it. |
needs_response | A chargeback is open. Send evidence before evidence_details.due_by, or accept it. |
under_review | Your evidence is with the bank, and no ruling has come back yet. |
won | The ruling went your way. |
lost | The ruling went the buyer's way, or you accepted the chargeback. |
prevented | The case ended before it turned into a chargeback. |
Events#
| Event | When |
|---|---|
charge.dispute.created | A charge of yours has a new dispute. |
charge.dispute.updated | Something on the dispute changed, such as its status after you submit evidence. |
charge.dispute.funds_withdrawn | The disputed amount was taken from your balance. |
charge.dispute.closed | The case is over; status is won, lost or warning_closed. |
charge.dispute.funds_reinstated | The disputed amount was put back in your balance. |
In every one of these events, data.object is the full Dispute, the same object GET /v1/disputes/{id} returns.
Find your disputes#
GET /v1/disputes pages through your disputes with the newest first. To see only one payment's disputes, add payment_intent or charge.
curl -G https://api.fluveo.com/v1/disputes \
-H "Authorization: Bearer sk_test_..." \
-d limit=10const response = await fetch("https://api.fluveo.com/v1/disputes?limit=10", {
headers: { Authorization: "Bearer sk_test_..." },
});
const { data, has_more } = await response.json();import requests
disputes = requests.get(
"https://api.fluveo.com/v1/disputes",
headers={"Authorization": "Bearer sk_test_..."},
params={"limit": 10},
).json()<?php
$ch = curl_init("https://api.fluveo.com/v1/disputes?limit=10");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer sk_test_..."],
]);
$disputes = json_decode(curl_exec($ch), true);1 to 100. The default is 10.
Only the disputes of this Charge (
ch_...). Send this orpayment_intent, not both.Only the disputes of this PaymentIntent.
A dispute id: the page after it. Send this or
ending_before, not both.A dispute id: the page before it.
Filter by creation time with
created[gt],created[gte],created[lt]orcreated[lte], as Unix timestamps.
To read a single dispute, call GET /v1/disputes/{id}. Add expand[]=charge or expand[]=payment_intent to get its Charge or its PaymentIntent in the same answer; on a list, write expand[]=data.charge. Every field of the Dispute is in the API reference.
Send evidence#
Your evidence should show the order was real and reached the buyer: the order receipt, the tracking number, the messages you exchanged. Facts you can type go in the text fields of evidence. A document goes in a file field, named by the file_ id it got when you uploaded it. You can save the evidence as a draft as many times as you need, then submit it once.
A new dispute already holds some evidence, copied from the payment when the dispute opened: the buyer's email address, name and address (billing_address), the shipping address and the payment's description. evidence_details.has_evidence stays false until you write. Read the dispute before you post: the keys you send replace those values (an empty value clears one), and the keys you leave out stay. The first events, charge.dispute.created and charge.dispute.funds_withdrawn, show those keys empty; GET /v1/disputes/{id} shows them filled.
Upload your evidence files#
Send each document to
POST /v1/fileswithpurpose=dispute_evidence(the API reference lists the file fields). Note thefile_id in the answer: anevidence[...]file field takes it in the next step.Upload a receiptcurl https://api.fluveo.com/v1/files \ -H "Authorization: Bearer sk_test_..." \ -F purpose=dispute_evidence \ -F "file=@receipt.pdf;type=application/pdf"import { readFile } from "node:fs/promises"; const form = new FormData(); form.append("purpose", "dispute_evidence"); form.append("file", new Blob([await readFile("receipt.pdf")], { type: "application/pdf" }), "receipt.pdf"); const response = await fetch("https://api.fluveo.com/v1/files", { method: "POST", headers: { Authorization: "Bearer sk_test_..." }, body: form, }); const file = await response.json();import requests with open("receipt.pdf", "rb") as document: file = requests.post( "https://api.fluveo.com/v1/files", headers={"Authorization": "Bearer sk_test_..."}, data={"purpose": "dispute_evidence"}, files={"file": ("receipt.pdf", document, "application/pdf")}, ).json()<?php $ch = curl_init("https://api.fluveo.com/v1/files"); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ["Authorization: Bearer sk_test_..."], CURLOPT_POSTFIELDS => [ "purpose" => "dispute_evidence", "file" => new CURLFile("receipt.pdf", "application/pdf", "receipt.pdf"), ], ]); $file = json_decode(curl_exec($ch), true);Save a draft of your evidence#
Post the evidence to the dispute with
submit=false. Fluveo keeps it on the dispute and the bank sees nothing yet; post again to change it, until you submit.Save evidence as a draftcurl https://api.fluveo.com/v1/disputes/du_... \ -H "Authorization: Bearer sk_test_..." \ -H "Idempotency-Key: dispute-du-draft-1" \ --data-urlencode "evidence[uncategorized_text]=The buyer received the order on 2 November." \ -d "evidence[receipt]=file_..." \ -d submit=falseconst response = await fetch("https://api.fluveo.com/v1/disputes/du_...", { method: "POST", headers: { Authorization: "Bearer sk_test_...", "Idempotency-Key": "dispute-du-draft-1", }, body: new URLSearchParams({ "evidence[uncategorized_text]": "The buyer received the order on 2 November.", "evidence[receipt]": "file_...", submit: "false", }), });import requests requests.post( "https://api.fluveo.com/v1/disputes/du_...", headers={ "Authorization": "Bearer sk_test_...", "Idempotency-Key": "dispute-du-draft-1", }, data={ "evidence[uncategorized_text]": "The buyer received the order on 2 November.", "evidence[receipt]": "file_...", "submit": "false", }, )<?php $ch = curl_init("https://api.fluveo.com/v1/disputes/du_..."); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer sk_test_...", "Idempotency-Key: dispute-du-draft-1", ], CURLOPT_POSTFIELDS => http_build_query([ "evidence[uncategorized_text]" => "The buyer received the order on 2 November.", "evidence[receipt]" => "file_...", "submit" => "false", ]), ]); curl_exec($ch);Submit the evidence#
Post the final evidence with
submit=true. Leavingsubmitout submits too, so always sendsubmit=falsewhile you draft. After it is submitted, the evidence is fixed, and the dispute'sstatusturns tounder_review.Submit evidencecurl https://api.fluveo.com/v1/disputes/du_... \ -H "Authorization: Bearer sk_test_..." \ -H "Idempotency-Key: dispute-du-1-evidence" \ -d "evidence[product_description]=Blue T-shirt, size M" \ -d "evidence[customer_email_address]=buyer@example.com" \ -d "evidence[shipping_tracking_number]=1Z999AA10123456784" \ -d "evidence[uncategorized_text]=The buyer signed for the parcel on 2026-10-02." \ -d submit=trueconst response = await fetch("https://api.fluveo.com/v1/disputes/du_...", { method: "POST", headers: { Authorization: "Bearer sk_test_...", "Idempotency-Key": "dispute-du-1-evidence", }, body: new URLSearchParams({ "evidence[product_description]": "Blue T-shirt, size M", "evidence[customer_email_address]": "buyer@example.com", "evidence[shipping_tracking_number]": "1Z999AA10123456784", "evidence[uncategorized_text]": "The buyer signed for the parcel on 2026-10-02.", submit: "true", }), }); const dispute = await response.json(); console.log(dispute.status);import requests response = requests.post( "https://api.fluveo.com/v1/disputes/du_...", headers={ "Authorization": "Bearer sk_test_...", "Idempotency-Key": "dispute-du-1-evidence", }, data={ "evidence[product_description]": "Blue T-shirt, size M", "evidence[customer_email_address]": "buyer@example.com", "evidence[shipping_tracking_number]": "1Z999AA10123456784", "evidence[uncategorized_text]": "The buyer signed for the parcel on 2026-10-02.", "submit": "true", }, ) print(response.json()["status"])<?php $ch = curl_init("https://api.fluveo.com/v1/disputes/du_..."); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer sk_test_...", "Idempotency-Key: dispute-du-1-evidence", ], CURLOPT_POSTFIELDS => http_build_query([ "evidence" => [ "product_description" => "Blue T-shirt, size M", "customer_email_address" => "buyer@example.com", "shipping_tracking_number" => "1Z999AA10123456784", "uncategorized_text" => "The buyer signed for the parcel on 2026-10-02.", ], "submit" => "true", ]), ]); echo json_decode(curl_exec($ch), true)["status"];
A submission sends every evidence field you saved: all your documents and all your texts. Each document is a PDF, JPEG or PNG of at most 5 MB, and the documents together are at most 4.5 MB and fewer than 50 pages. Text alone is a valid submission. If the bank cannot take what you sent, nothing is lost: the dispute keeps your evidence and goes back to needs_response with submission_count 0, a charge.dispute.updated says so, and the Dashboard shows why it was not sent. Fix it and submit again.
Once a submission has gone to the bank, the dispute takes no more evidence. Any new evidence, the same as before or different, with submit=true or submit=false, answers 400 "You've reached the maximum number of evidence submissions for this dispute." A post with only metadata still works, and sending the submission again with its first Idempotency-Key returns its first answer. One exception: when an inquiry you answered turns into a chargeback, the chargeback takes one more submission.
One key per fact you want the bank to see. All keys are optional: pick those that match the case. Every key is listed under Evidence fields.
4 child fields
What you sold, up to 20,000 characters.
Anything that does not fit another field, up to 20,000 characters.
The
file_id of the receipt you uploaded.The
ch_of the other charge, when the buyer says they paid twice.
falsesaves a draft.true, or no value at all, submits the evidence to the bank.Key-value pairs you keep on the dispute for your own use.
Evidence fields#
Each key goes in the form as evidence[<name>]. A text key takes plain text, and a file key takes the file_... id of an upload.
| Kind | evidence[...] keys |
|---|---|
| Long text, up to 20,000 characters | access_activity_log, cancellation_policy_disclosure, cancellation_rebuttal, duplicate_charge_explanation, product_description, refund_policy_disclosure, refund_refusal_explanation, uncategorized_text |
| Short text, up to 5,000 characters | billing_address, customer_email_address, customer_name, customer_purchase_ip, service_date, shipping_address, shipping_carrier, shipping_date, shipping_tracking_number |
File, a file_ id | cancellation_policy, customer_communication, customer_signature, duplicate_charge_documentation, receipt, refund_policy, service_documentation, shipping_documentation, uncategorized_file |
| Charge id | duplicate_charge_id |
POST /v1/disputes/{id} takes these keys, submit and metadata, nothing else. Any other field, evidence[enhanced_evidence] included, is refused with a 400 whose param names it. Evidence that the case cannot take is refused the same way, so read param, fix that field and post again.
Accept a dispute#
When the buyer is right, or the order is not worth defending, call POST /v1/disputes/{id}/close with an Idempotency-Key. That gives the chargeback up for good: the buyer keeps the amount, the dispute ends as lost, and charge.dispute.closed reaches your endpoint. A closed dispute cannot be opened again.
curl -X POST https://api.fluveo.com/v1/disputes/du_.../close \
-H "Authorization: Bearer sk_test_..." \
-H "Idempotency-Key: dispute-du-1-close"Test it#
In test mode, pay with one of the PaymentMethods below and a dispute opens on that charge by itself. It is already open when the payment's answer reaches you.
| PaymentMethod | What happens |
|---|---|
pm_card_createDispute | The charge gets a chargeback in needs_response, reason fraudulent. |
pm_card_createDisputeProductNotReceived | The same, with the buyer claiming the order never arrived (reason product_not_received). |
pm_card_createDisputeInquiry | The charge gets an inquiry in warning_needs_response. |
pm_card_createMultipleDisputes | The charge gets two fraudulent chargebacks, each of half the amount. With an odd amount, the first takes the smaller half and the second the rest. |
pm_card_createCe3EligibleDispute | A fraudulent chargeback that the card network marks eligible for Visa Compelling Evidence 3.0: enhanced_eligibility_types is ["visa_compelling_evidence_3"]. |
pm_card_createComplianceDispute | A compliance dispute, reason noncompliant. |
pm_card_createAutoRepresentmentEligibleDispute | The same as pm_card_createDispute. |
pm_card_createRapidDisputeResolutionDispute | The same as pm_card_createDispute. |
pm_card_createCe3BlockDispute | The same as pm_card_createDispute. |
- Create a PaymentIntent for
amount=2000(in the smallest unit of your currency) withpayment_method_types[]=card,payment_method=pm_card_createDisputeandconfirm=true. - The dispute is already open when the payment answers: list
GET /v1/disputes?payment_intent=pi_..., or catchcharge.dispute.createdon your endpoint, and save the dispute id. - Post
evidence[uncategorized_text]=...withsubmit=false. The draft is kept andstatusstaysneeds_response. - Post it again with
submit=true.charge.dispute.updatedarrives andstatusisunder_review. - Make a second
pm_card_createDisputepayment and call/closeon its dispute.charge.dispute.closedarrives withstatuslost.
Next#
- Balance and payouts: what a dispute does to your balance and to your next payout.
- Refund a payment: refunding an unhappy buyer early can save you a dispute.
- The Disputes reference
- Event types