---
title: "Handle disputes"
description: "Find card disputes, send evidence, or accept them."
url: https://docs.fluveo.com/payments/disputes/
---

> Index of every page: https://docs.fluveo.com/llms.txt. Integrating Fluveo? Read https://docs.fluveo.com/agents/integrate.md first.

> Too long? This page with code in one language only: https://docs.fluveo.com/payments/disputes.curl.md (cURL), https://docs.fluveo.com/payments/disputes.node.md (Node.js), https://docs.fluveo.com/payments/disputes.python.md (Python), https://docs.fluveo.com/payments/disputes.php.md (PHP).

# Handle disputes

Find card disputes, send evidence, or accept them.

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.

> **Keep the whole id:** Save a dispute's id exactly as it arrives and send it back unchanged. It starts with `du_` today, but your code should never read meaning into it.

## From the bank's case to the final ruling

1. The case opens. A Dispute appears on the charge and `charge.dispute.created` reaches your endpoint.
2. When the amount comes out of your balance, `charge.dispute.funds_withdrawn` follows.
3. A new chargeback has `status` `needs_response`. A question the bank asks before any chargeback (an inquiry) has `warning_needs_response`. Either way, `evidence_details.due_by` holds your deadline as a Unix timestamp.
4. Before that deadline, submit evidence or close the dispute to accept it. Submitted evidence moves the dispute to `under_review` and sends `charge.dispute.updated`.
5. The ruling arrives as `charge.dispute.closed`, with `status` set to `won` or `lost` (`warning_closed` when an inquiry ends). A win that returns the money also sends `charge.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](https://docs.fluveo.com/payments/payouts.md).

## 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`.

**List open disputes**

cURL:

```bash
curl -G https://api.fluveo.com/v1/disputes \
  -H "Authorization: Bearer sk_test_..." \
  -d limit=10
```

Node.js:

```javascript
const response = await fetch("https://api.fluveo.com/v1/disputes?limit=10", {
  headers: { Authorization: "Bearer sk_test_..." },
});
const { data, has_more } = await response.json();
```

Python:

```python
import requests

disputes = requests.get(
    "https://api.fluveo.com/v1/disputes",
    headers={"Authorization": "Bearer sk_test_..."},
    params={"limit": 10},
).json()
```

PHP:

```php
<?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);
```

**Query parameters**

- `limit` (integer, optional): 1 to 100. The default is 10.
- `charge` (string, optional): Only the disputes of this Charge (`ch_...`). Send this or `payment_intent`, not both.
- `payment_intent` (string, optional): Only the disputes of this PaymentIntent.
- `starting_after` (string, optional): A dispute id: the page after it. Send this or `ending_before`, not both.
- `ending_before` (string, optional): A dispute id: the page before it.
- `created` (object, optional): Filter by creation time with `created[gt]`, `created[gte]`, `created[lt]` or `created[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](https://docs.fluveo.com/api/disputes.md).

## 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.

### Step 1: Upload your evidence files

Send each document to `POST /v1/files` with `purpose=dispute_evidence` ([the API reference](https://docs.fluveo.com/api/files.md) lists the file fields). Note the `file_` id in the answer: an `evidence[...]` file field takes it in the next step.

**Upload a receipt**

cURL:

```bash
curl https://api.fluveo.com/v1/files \
  -H "Authorization: Bearer sk_test_..." \
  -F purpose=dispute_evidence \
  -F "file=@receipt.pdf;type=application/pdf"
```

Node.js:

```javascript
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();
```

Python:

```python
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:

```php
<?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);
```

### Step 2: 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 draft**

cURL:

```bash
curl 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=false
```

Node.js:

```javascript
const 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",
  }),
});
```

Python:

```python
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:

```php
<?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);
```

### Step 3: Submit the evidence

Post the final evidence with `submit=true`. Leaving `submit` out submits too, so **always send `submit=false` while you draft**. After it is submitted, the evidence is fixed, and the dispute's `status` turns to `under_review`.

**Submit evidence**

cURL:

```bash
curl 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=true
```

Node.js:

```javascript
const 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);
```

Python:

```python
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:

```php
<?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.

**Parameters**

- `evidence` (object, optional): 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](https://docs.fluveo.com/payments/disputes.md#evidence-fields).
  - `product_description` (string, optional): What you sold, up to 20,000 characters.
  - `uncategorized_text` (string, optional): Anything that does not fit another field, up to 20,000 characters.
  - `receipt` (string, optional): The `file_` id of the receipt you uploaded.
  - `duplicate_charge_id` (string, optional): The `ch_` of the other charge, when the buyer says they paid twice.
- `submit` (boolean, optional): `false` saves a draft. `true`, or no value at all, submits the evidence to the bank.
- `metadata` (object, optional): 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.

*Close (accept) a dispute*

```bash
curl -X POST https://api.fluveo.com/v1/disputes/du_.../close \
  -H "Authorization: Bearer sk_test_..." \
  -H "Idempotency-Key: dispute-du-1-close"
```

> **Inquiries:** `/close` on an inquiry (`warning_needs_response`) answers 200 but leaves the inquiry open. To end an inquiry, submit evidence with `submit=true`.

## 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`. |

1. Create a PaymentIntent for `amount=2000` (in the smallest unit of your currency) with `payment_method_types[]=card`, `payment_method=pm_card_createDispute` and `confirm=true`.
2. The dispute is already open when the payment answers: list `GET /v1/disputes?payment_intent=pi_...`, or catch `charge.dispute.created` on your endpoint, and save the dispute id.
3. Post `evidence[uncategorized_text]=...` with `submit=false`. The draft is kept and `status` stays `needs_response`.
4. Post it again with `submit=true`. `charge.dispute.updated` arrives and `status` is `under_review`.
5. Make a second `pm_card_createDispute` payment and call `/close` on its dispute. `charge.dispute.closed` arrives with `status` `lost`.

> **For AI agents:**
>
> Disputes for agents: react to charge.dispute.created; read the dispute id from data.object.id and store it as an opaque string (it starts du_, but never parse it). Answer before evidence_details.due_by with POST /v1/disputes/{id} (evidence[...] fields) or accept with POST /v1/disputes/{id}/close. POST /v1/disputes/{id} submits the evidence when submit is absent: always send submit=false until the user confirms the defence is final, then submit=true. Never send enhanced_evidence: it is refused. Evidence files are file_ ids from POST /v1/files with purpose=dispute_evidence, for every store. Every POST carries an Idempotency-Key.

## Next

- [Balance and payouts](https://docs.fluveo.com/payments/payouts.md): what a dispute does to your balance and to your next payout.
- [Refund a payment](https://docs.fluveo.com/payments/refunds.md): refunding an unhappy buyer early can save you a dispute.
- [The Disputes reference](https://docs.fluveo.com/api/disputes.md)
- [Event types](https://docs.fluveo.com/webhooks/events.md)
