Docs
API 2026-05-27.dahlia Dashboard

Payments

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.

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.

Dispute statuses#

StatusMeaning
warning_needs_responseThe bank wants details before it files any chargeback. A good answer can keep it from filing one.
warning_under_reviewYour answer to the inquiry is with the bank.
warning_closedThe inquiry is over and no chargeback came of it.
needs_responseA chargeback is open. Send evidence before evidence_details.due_by, or accept it.
under_reviewYour evidence is with the bank, and no ruling has come back yet.
wonThe ruling went your way.
lostThe ruling went the buyer's way, or you accepted the chargeback.
preventedThe case ended before it turned into a chargeback.

Events#

EventWhen
charge.dispute.createdA charge of yours has a new dispute.
charge.dispute.updatedSomething on the dispute changed, such as its status after you submit evidence.
charge.dispute.funds_withdrawnThe disputed amount was taken from your balance.
charge.dispute.closedThe case is over; status is won, lost or warning_closed.
charge.dispute.funds_reinstatedThe 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 -G https://api.fluveo.com/v1/disputes \
  -H "Authorization: Bearer sk_test_..." \
  -d limit=10
const 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);
Query parameters
  • limitintegerOptional

    1 to 100. The default is 10.

  • chargestringOptional

    Only the disputes of this Charge (ch_...). Send this or payment_intent, not both.

  • payment_intentstringOptional

    Only the disputes of this PaymentIntent.

  • starting_afterstringOptional

    A dispute id: the page after it. Send this or ending_before, not both.

  • ending_beforestringOptional

    A dispute id: the page before it.

  • createdobjectOptional

    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.

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.

  1. Upload your evidence files#

    Send each document to POST /v1/files with purpose=dispute_evidence (the API reference 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 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);
  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 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
    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",
      }),
    });
    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);
  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 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
    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);
    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.

Parameters
  • evidenceobjectOptional

    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
    • product_descriptionstringOptional

      What you sold, up to 20,000 characters.

    • uncategorized_textstringOptional

      Anything that does not fit another field, up to 20,000 characters.

    • receiptstringOptional

      The file_ id of the receipt you uploaded.

    • duplicate_charge_idstringOptional

      The ch_ of the other charge, when the buyer says they paid twice.

  • submitbooleanOptional

    false saves a draft. true, or no value at all, submits the evidence to the bank.

  • metadataobjectOptional

    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.

Kindevidence[...] keys
Long text, up to 20,000 charactersaccess_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 charactersbilling_address, customer_email_address, customer_name, customer_purchase_ip, service_date, shipping_address, shipping_carrier, shipping_date, shipping_tracking_number
File, a file_ idcancellation_policy, customer_communication, customer_signature, duplicate_charge_documentation, receipt, refund_policy, service_documentation, shipping_documentation, uncategorized_file
Charge idduplicate_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
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.

PaymentMethodWhat happens
pm_card_createDisputeThe charge gets a chargeback in needs_response, reason fraudulent.
pm_card_createDisputeProductNotReceivedThe same, with the buyer claiming the order never arrived (reason product_not_received).
pm_card_createDisputeInquiryThe charge gets an inquiry in warning_needs_response.
pm_card_createMultipleDisputesThe 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_createCe3EligibleDisputeA fraudulent chargeback that the card network marks eligible for Visa Compelling Evidence 3.0: enhanced_eligibility_types is ["visa_compelling_evidence_3"].
pm_card_createComplianceDisputeA compliance dispute, reason noncompliant.
pm_card_createAutoRepresentmentEligibleDisputeThe same as pm_card_createDispute.
pm_card_createRapidDisputeResolutionDisputeThe same as pm_card_createDispute.
pm_card_createCe3BlockDisputeThe 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.

Next#