Card disputes
When one of your cards paid for something that never arrived, wasn’t as described, was charged
twice or wasn’t yours, a card dispute (card_dispute, cdsp_…) asks the card network for the
money back. It’s your business’s statement to the network, so ask the merchant for a refund first:
the network expects you to have. A customer disputing a payment to you is covered under
disputes.
The lifecycle
Section titled “The lifecycle”- Draft.
POST /v1/card_transactions/{id}/disputeswith areason, any evidence, andsubmit: false. Only a capture (money a merchant took) can be disputed, never an authorisation, a refund or a push payment. One open dispute per purchase. - Evidence.
POST /v1/card_disputes/{id}adds evidence or changes the reason or amount. Fields you send replace what’s saved;nullclears one. - Files.
POST /v1/card_disputes/{id}/filesattaches a PDF or JPEG. - Submit.
POST /v1/card_disputes/{id}/submit. Only one submission is allowed, with nothing added after, so we check the evidence first. - Decided. The network decides
wonorlost. A draft not submitted by the network’s deadline endsexpired.
One step: submit: true on create (the default for a secret key) files and submits in one
request, with the same checks as a submission. It suits a fraud dispute, which needs only an
explanation.
curl https://api.tuppence.ai/v1/card_transactions/ctx_…/disputes \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "reason": "not_received", "submit": false, "evidence": { "product_type": "merchandise", "product_description": "12 boxes of printer paper, order 4471", "expected_at": 1790208000 } }'
curl -X POST https://api.tuppence.ai/v1/card_disputes/cdsp_…/submit \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY"import { Tuppence } from "@tuppence/node";
const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY);
const draft = await tuppence.cardDisputes.create("ctx_…", { reason: "not_received", submit: false, evidence: { product_type: "merchandise", product_description: "12 boxes of printer paper, order 4471", expected_at: 1790208000, },});await tuppence.cardDisputes.update(draft.id, { explanation: "Ordered on 1 October for delivery by the 5th. Nothing arrived and the merchant stopped answering.",});const sent = await tuppence.cardDisputes.submit(draft.id);console.log(sent.status); // submittedimport osfrom tuppence import Tuppence
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])
draft = tuppence.card_disputes.create( "ctx_…", reason="not_received", submit=False, evidence={ "product_type": "merchandise", "product_description": "12 boxes of printer paper, order 4471", "expected_at": 1790208000, },)sent = tuppence.card_disputes.submit(draft.id)| Field or call | |
|---|---|
status | unsubmitted (draft), submitted, won, lost, expired |
amount | Minor units. Defaults to what the merchant took and hasn’t returned: refunds received and other disputes of the purchase are subtracted. More is 400 parameter_invalid (param: amount) |
explanation | Top-level shortcut for the evidence’s explanation: 10–2,000 characters, the words the network reads |
prefill: true | Fills evidence from your agent’s records (below). A draft created with no evidence is prefilled anyway |
GET /v1/card_disputes | Newest first; ?card=, ?status=. GET /v1/card_disputes/{id} reads one. Reads need card_transactions:read, writes card_transactions:write |
The evidence each reason needs
Section titled “The evidence each reason needs”A submission missing its reason’s required fields is refused, so we check first. Until they’re
all there, submitting returns 400 dispute_evidence_incomplete. Its missing_evidence lists each
missing field and, in plain words, what to ask for.
reason | Required before submitting |
|---|---|
fraudulent | explanation: you didn’t make this purchase and don’t know who did |
no_valid_authorization | explanation |
not_received | product_type, product_description, expected_at |
canceled | product_type, product_description, expected_at, cancellation_policy_provided, canceled_at, cancellation_reason. For goods (product_type: merchandise), also returned_at and return_status |
merchandise_not_as_described | received_at, return_status, returned_at, return_description, explanation |
service_not_as_described | received_at, canceled_at, cancellation_reason, explanation |
duplicate | Exactly one proof it was paid already: original_transaction (the original ctx_…), or a file as card_statement, cash_receipt or check_image |
other | product_type, product_description, explanation |
| Field type | Format |
|---|---|
Dates: expected_at, received_at, canceled_at, returned_at | Unix seconds. returned_at after received_at |
product_type | merchandise (goods) or service |
return_status | successful (they took it back) or merchant_rejected |
cancellation_policy_provided | true or false |
| Text | Up to 2,000 characters |
| Files | Not here: use POST /v1/card_disputes/{id}/files |
{ "error": { "type": "invalid_request_error", "code": "dispute_evidence_incomplete", "message": "Before a not received dispute can be submitted, add: when it should have arrived.", "param": "evidence[expected_at]", "missing_evidence": [{ "field": "expected_at", "label": "when it should have arrived" }], "request_id": "req_…" }}Drafted from your agent’s records. When one of your agents made the purchase, the draft starts
from what it knows: what was ordered, from whom (its cart, or a person’s approval), when, and the
mandate it bought under. The evidence then has drafted_from_trail: true. Check it before you
submit.
Attach a PDF or JPEG up to 5 MB (the networks’ limit) to a draft, as multipart/form-data: a
file part and, optionally, a field part naming the evidence field.
curl https://api.tuppence.ai/v1/card_disputes/cdsp_…/files \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -F file=@delivery-tracking.pdf \ -F field=additional_documentationimport { readFile } from "node:fs/promises";
await tuppence.cardDisputes.uploadFile("cdsp_…", { file: await readFile("delivery-tracking.pdf"), filename: "delivery-tracking.pdf",});with open("delivery-tracking.pdf", "rb") as f: tuppence.card_disputes.upload_file("cdsp_…", file=f, field="additional_documentation")fielddefaults toadditional_documentation. Aduplicatedispute can also takecard_statement,cash_receiptorcheck_image. That is its one proof of payment, so it replaces any other.- One file per field; a new one replaces the last. Send a multi-page document as one PDF.
- The file is stored on your account. We keep its File id (
file_…), name, type and size infiles, never the bytes. - We read the type from the file’s first bytes, not from what the upload claims.
- MCP can’t attach files: attach them here or in the dashboard.
Deadlines
Section titled “Deadlines”| Network and reason | Window after the purchase clears |
|---|---|
Visa, no_valid_authorization | 65 days |
Mastercard, duplicate | 80 days |
| Everything else | 110 days (a draft expires then) |
| Date | |
|---|---|
dispute_by | The network’s deadline less a 5-day margin (Unix seconds). Submit by then |
| Reminder | A draft still unsubmitted 7 days before dispute_by: email to the account’s owners, a push and an alert |
| Too late | Filing or submitting after the network’s deadline: 400 dispute_window_closed, with dispute_by |
The network’s rules, checked first
Section titled “The network’s rules, checked first”We check the card networks’ rules before the dispute is filed, so any refusal comes from us, in plain words, and your draft is kept.
| Rule | Refusal |
|---|---|
35 fraud disputes per card in 120 days. The networks reject more fraudulent disputes on one card | 400 dispute_not_allowed |
Cancelled, then wait 15 days. canceled and service_not_as_described are rejected within 15 days of cancelling, because the merchant has until then to refund | 400 dispute_not_allowed, with available_at (the first moment it’s accepted) |
| Refunds are subtracted. Only what hasn’t come back | 400 parameter_invalid on amount |
| Only captures. Not an authorisation, refund or push payment | 400 parameter_invalid |
The right to dispute fraud is never limited. Card limits act on cards, spend and provisional credit, never on filing a dispute.
Fraud: the card is cancelled and replaced
Section titled “Fraud: the card is cancelled and replaced”Filing fraudulent cancels the card (as stolen). This is required, because while the
card stays active every later fraud dispute on it is lost. In the same step we issue a
replacement with the same spending policy, agent and limits (replacement_card on the dispute).
A fraud replacement is always allowed once, whatever the card limits say. A
single-use card is cancelled, not replaced.
The response also lists the card’s other purchases from the last 30 days as
other_recent_purchases, so you can ask the cardholder whether those were theirs too.
Money: provisional credit and the outcome
Section titled “Money: provisional credit and the outcome”In the UK and the EEA, for fraudulent and no_valid_authorization disputes, Tuppence credits the
disputed amount to your cards’ balance while the network decides, usually by the next business
day. This is provisional credit. The dispute’s provisional_credit shows amount and status
(none, credited, reversed).
| New accounts hold it | On Probation and Starter (and before review), it’s in your balance but purchases can’t spend it until the dispute is decided. Such a purchase is declined with funds_under_review. Established and Trusted can spend it at once. issuing_provisional_credit in your card limits says which applies |
| Always reversed | When decided, won or lost, it’s taken back. A win is then credited separately (issuing_dispute), so nothing counts twice |
balance_transactions | Each movement: type (issuing_dispute_provisional_credit, issuing_dispute_provisional_credit_reversal, issuing_dispute) and a signed amount |
| Won | The money returns to what your cards can spend; cashback earned on the purchase is reversed |
Lost (or expired) | loss_reason is the network’s code; loss_reason_text says it in plain words, e.g. “The purchase was approved with 3D Secure, so the network treats it as yours.” |
| No dispute fee | The network charges a fee for every card dispute. Tuppence pays it, never your business |
Agents draft, people submit
Section titled “Agents draft, people submit”A dispute is the business’s statement to the network, so a person submits it. An agent can do everything before that.
| Who | Can |
|---|---|
Agent, restricted key (rk_…) | Create drafts, add evidence, attach files. submit defaults to false; submitting (POST …/submit or submit: true) is 403 dispute_submission_needs_person, and the draft is kept |
| Agent, MCP | submit_card_dispute checks the evidence, opens an approval for a person and returns approval_id. If approved in the dashboard or app, it’s submitted as that person; if declined, the draft is unchanged. The approval waits until dispute_by or a week, whichever comes first. See MCP |
| A person (secret key, dashboard, app) | File and submit, always |
Notices and events
Section titled “Notices and events”We email the account’s owners when a dispute is submitted, won, lost or expired, and send the 7-day reminder. For a connected account, the emails go to the platform’s owners and name the business.
Event (each carries the card_dispute) | When |
|---|---|
card_dispute.created | Filed: a draft, or filed and submitted |
card_dispute.updated | Evidence, files, reason or amount changed |
card_dispute.submitted | Sent to the card network |
card_dispute.funds_reinstated | Money back to your cards’ balance: provisional credit, or a win |
card_dispute.funds_rescinded | Money taken back: usually the provisional credit, at resolution |
card_dispute.closed | Decided (won, lost), or expired |
A platform’s Connect endpoint receives its connected accounts’ card dispute events, each with
account. See Webhooks.
In test mode
Section titled “In test mode”| Do | Result |
|---|---|
explanation of winning_evidence or losing_evidence | A submitted dispute is decided at once: won or lost. Any other explanation stays submitted |
POST /v1/test_helpers/card_disputes/{id}/close with outcome | won or lost decides a submitted dispute; expired runs a draft out of time |
See Testing.
Errors
Section titled “Errors”| Code | HTTP | Means |
|---|---|---|
dispute_evidence_incomplete | 400 | The reason’s evidence isn’t complete: see missing_evidence |
dispute_window_closed | 400 | Too late for this reason on this card’s network. dispute_by was the date |
dispute_not_allowed | 400 | A network rule refuses it now: within 15 days of cancelling (available_at says when), or the card’s 35 fraud disputes in 120 days |
dispute_submission_needs_person | 403 | An agent’s key tried to submit. A person submits, or approves the agent’s request |
invalid_state | 400 | Already submitted or decided, or the purchase already has an open dispute |
parameter_invalid | 400 | Not a capture, or amount above what can be disputed |