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.

  1. Stripecharge.dispute.*
    FilesChargeback files
  2. CollectsCoris
  3. PortalDisputes
  4. Ends inOutcome

For reviewers

Reviewers work each chargeback on the Disputes page in the portal.

FieldWhat it means
ReasonWhy the cardholder disputed the payment, such as fraudulent or product not received.
StatusWhere the case stands with the processor, such as needs response, under review, won or lost.
Internal statusYour team's own stage for the case. Admins set these stages on the Dispute Statuses page.
Dispute amount and dateHow much is disputed, and when the dispute opened.
Payment network and issuing bankThe card network and the cardholder's bank.
Win likelihoodHow often similar past disputes were won over the last 12 months, when there is enough history.
Linked transactionsPayments that match the dispute, with the signals that matched, such as the card or email.

To record an outcome:

  1. Open the dispute, and check the reason, the due date and the linked transactions.
  2. Add notes, and upload evidence that fits the reason.
  3. 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.
  4. 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 familyWhat the cardholder saysEvidence that usually wins
FraudThey 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 receivedThe 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 describedThe 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 errorsThey 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\" }
  }" | jq
response = 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.

FieldMeaningWhat to do
transaction_idYour ID, echoed back.Match it to your record.
disputedtrue 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.

SituationAction
Clear proof of deliveryContest, with evidence.
Low amount and weak evidenceAccept the dispute.
Repeat disputes on one merchantReview 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


Did this page help you?