Respond to disputes
Work chargebacks and evidence in the Coris portal, with disputes loaded from your processor connection or your files.
Your team works every chargeback in the Coris portal. Disputes arrive from your processor connection or your files.
- Stripecharge.
dispute. * FilesChargeback files - CollectsCoris
- PortalDisputes
- Ends inOutcome
For reviewers
Reviewers work each chargeback on the Disputes page in the portal.
| Field | What it means |
|---|---|
| Reason | Why the cardholder disputed the payment, such as fraudulent or product not received. |
| Status | Where the case stands with the processor, such as needs response, under review, won or lost. |
| Internal status | Your team's own stage for the case. Admins set these stages on the Dispute Statuses page. |
| Dispute amount and date | How much is disputed, and when the dispute opened. |
| Payment network and issuing bank | The card network and the cardholder's bank. |
| Win likelihood | How often similar past disputes were won over the last 12 months, when there is enough history. |
| Linked transactions | Payments that match the dispute, with the signals that matched, such as the card or email. |
To record an outcome:
- Open the dispute, and check the reason, the due date and the linked transactions.
- Add notes, and upload evidence that fits the reason.
- For a Stripe dispute, pick Fight to send your evidence, or Concede to accept the dispute. Fight stages the evidence in Stripe, and you confirm it in the Stripe Dashboard.
- Set the internal status, so your team can see where each case stands.
Evidence by reason
Most dispute reasons fall into one of four families. The evidence that usually wins differs for each.
| Reason family | What the cardholder says | Evidence that usually wins |
|---|---|---|
| Fraud | They did not make or allow the payment. | Proof the real cardholder paid: matching AVS and CVV, 3-D Secure, and earlier undisputed payments from the same card, device or IP address. |
| Not received | The goods or service never arrived. | Carrier tracking that shows delivery to the cardholder's address, or logs that show they used the service. |
| Not as described | The goods were faulty, not as shown, or canceled. | The product page and terms at the time of sale, messages with the customer, and proof of your refund or cancellation policy. |
| Processing errors | They were charged twice, charged the wrong amount, or not refunded. | Receipts that show separate purchases, the correct amount, or the refund you issued. |
Respond before the due date. A late response loses the dispute.
For developers
Step 1: Connect your dispute source
Connect Stripe to load its disputes, or load chargebacks by file.
Step 2 (optional): Mark a pushed transaction as disputed
Mark a pushed transaction so that your rates stay correct.
export CORIS_BASE_URL="https://sandbox-api.coris.ai/api/v0_2"
export CORIS_API_KEY="YOUR_API_KEY"import requests
BASE_URL = "https://sandbox-api.coris.ai/api/v0_2"
HEADERS = {"Authorization": "Token YOUR_API_KEY"}const BASE_URL = "https://sandbox-api.coris.ai/api/v0_2";
const HEADERS = {
Authorization: "Token YOUR_API_KEY",
"Content-Type": "application/json",
};The samples below wait with the polling helper. Expand it, and run it too.
Polling helper
# Polls an async endpoint and prints the body once it returns 200.
# 206, 425, 429 and 500 all mean wait and poll again. A 500 re-queues the job.
poll() {
delay=5
for _ in $(seq 1 30); do
http_status=$(curl -s -o /tmp/coris_body -w '%{http_code}' \
-H "Authorization: Token $CORIS_API_KEY" \
"$CORIS_BASE_URL$1?request_id=$2")
if [ "$http_status" = "200" ]; then cat /tmp/coris_body; return 0; fi
case "$http_status" in
206|425|429|500) ;;
*) echo "HTTP $http_status: $(cat /tmp/coris_body)" >&2; return 1 ;;
esac
sleep "$delay"
delay=$((delay * 3 / 2)); [ "$delay" -gt 30 ] && delay=30
done
echo "Timed out" >&2; return 1
}import time
# 206, 425, 429 and 500 all mean wait and poll again. A 500 re-queues the job.
RETRYABLE = (206, 425, 429, 500)
def poll(path, request_id, attempts=30):
"""Polls an async endpoint and returns the JSON body once it returns 200."""
delay = 5
for _ in range(attempts):
response = requests.get(
f"{BASE_URL}{path}", params={"request_id": request_id}, headers=HEADERS
)
if response.status_code == 200:
return response.json()
if response.status_code not in RETRYABLE:
response.raise_for_status()
time.sleep(delay)
delay = min(delay * 1.5, 30)
raise TimeoutError(f"{path} did not finish")// 206, 425, 429 and 500 all mean wait and poll again. A 500 re-queues the job.
const RETRYABLE = [206, 425, 429, 500];
// Polls an async endpoint and returns the JSON body once it returns 200.
async function poll(path, requestId, attempts = 30) {
let delay = 5000;
for (let i = 0; i < attempts; i++) {
const url = `${BASE_URL}${path}?request_id=${requestId}`;
const response = await fetch(url, { headers: HEADERS });
if (response.status === 200) return response.json();
if (!RETRYABLE.includes(response.status)) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
await new Promise((resolve) => setTimeout(resolve, delay));
delay = Math.min(delay * 1.5, 30000);
}
throw new Error(`${path} did not finish`);
}This call creates a test account. Use your own ACCOUNT_ID in production.
REFERENCE_ID="acme-plumbing-$(date +%s)"
REQUEST_ID=$(curl -s -X POST "$CORIS_BASE_URL/accounts/" \
-H "Authorization: Token $CORIS_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"reference_id\": \"$REFERENCE_ID\",
\"business_name\": \"Acme Plumbing Co.\",
\"address\": { \"address_type\": \"Registration\", \"city\": \"San Jose\", \"state\": \"CA\", \"country\": \"US\" }
}" | jq -r '.request_id')
ACCOUNT_ID=$(poll /accounts/ "$REQUEST_ID" | jq -r '.id')reference_id = f"acme-plumbing-{int(time.time())}"
response = requests.post(
f"{BASE_URL}/accounts/",
headers=HEADERS,
json={
"reference_id": reference_id,
"business_name": "Acme Plumbing Co.",
"address": {
"address_type": "Registration",
"city": "San Jose",
"state": "CA",
"country": "US",
},
},
)
response.raise_for_status()
account_id = poll("/accounts/", response.json()["request_id"])["id"]const referenceId = `acme-plumbing-${Date.now()}`;
const accountJob = await fetch(`${BASE_URL}/accounts/`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({
reference_id: referenceId,
business_name: "Acme Plumbing Co.",
address: { address_type: "Registration", city: "San Jose", state: "CA", country: "US" },
}),
});
if (!accountJob.ok) throw new Error(await accountJob.text());
const accountRequestId = (await accountJob.json()).request_id;
const accountId = (await poll("/accounts/", accountRequestId)).id;Reuse the original transaction_id. This marks the transaction and opens no case on the Disputes page.
curl -s -X POST "$CORIS_BASE_URL/accounts/$ACCOUNT_ID/transactions/" \
-H "Authorization: Token $CORIS_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"transaction_id\": \"txn-1001\",
\"amount\": 12500,
\"status\": \"disputed\",
\"dispute_data\": { \"amount\": 12500, \"disputed_date\": $(date +%s) },
\"payer\": { \"payment_method\": \"card\" }
}" | jqresponse = requests.post(
f"{BASE_URL}/accounts/{account_id}/transactions/",
headers=HEADERS,
json={
"transaction_id": "txn-1001",
"amount": 12500,
"status": "disputed",
"dispute_data": {"amount": 12500, "disputed_date": int(time.time())},
"payer": {"payment_method": "card"},
},
)
print(response.status_code, response.json())const disputed = await fetch(`${BASE_URL}/accounts/${accountId}/transactions/`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({
transaction_id: "txn-1001",
amount: 12500,
status: "disputed",
dispute_data: { amount: 12500, disputed_date: Math.floor(Date.now() / 1000) },
payer: { payment_method: "card" },
}),
});
console.log(disputed.status, await disputed.json());Try POST /accounts/{account_id}/transactions/ in the API explorer.
Send dispute_data.disputed_date in epoch seconds, using the date the network reported. Send dispute_data.amount in the smallest currency unit, or omit it if the full amount is disputed.
Read the response
Step 2 returns the transaction with 201 if it is new, or 200 if it already existed.
| Field | Meaning | What to do |
|---|---|---|
transaction_id | Your ID, echoed back. | Match it to your record. |
disputed | true once the transaction is marked. | Check this field, not status. |
What to do next
Decide each dispute with this example policy, and tune it to your risk appetite.
| Situation | Action |
|---|---|
| Clear proof of delivery | Contest, with evidence. |
| Low amount and weak evidence | Accept the dispute. |
| Repeat disputes on one merchant | Review the merchant. See Monitor your portfolio. |
To work disputes by API, you need the partner reference. Request access.
FAQ
Can I get a webhook for new disputes?
No. Check the portal for new disputes.
Are Adyen chargebacks included?
Not on the Disputes page. Coris stores Adyen chargeback notifications against the payment, and they open no dispute case.
Can I tag a dispute?
Use Set a transaction tag with entity_type set to dispute.
Next: Handle async requests
Updated about 23 hours ago