Skip to content
Coritan Docs

Answer disputes on store orders

Follow the chargebacks shoppers open on store orders, then send evidence to their bank or accept the dispute, with the Commerce API.

View as Markdown

A dispute, or chargeback, is a shopper asking their bank to take back what they paid for a store order. The bank holds the money while it decides, so we take the amount from your balance. You answer with evidence that the order was genuine and reached the shopper, or you accept the dispute and the shopper keeps the money.

This page covers disputes on store orders. For disputes on the invoices your customers pay, see Follow payment disputes. The routes are under https://api.coritan.com/api/v1/orgs/{org_slug}/commerce/disputes, and the API reference lists them under Commerce.

  • Commerce must be on for your organization, as Sell with the Commerce API describes.
  • Reading disputes takes an organization API key with the commerce.finance:read scope, or a member's token with the Billing role or above.
  • Changing evidence, sending it and accepting a dispute take commerce.disputes:write, or the Billing role or above. A key that answers disputes also needs commerce.finance:read to read them.
  • Set your store's policies, so that the evidence quotes them. Add your policies to the evidence shows how.

What a dispute does to your balance

Section titled What a dispute does to your balance

When a shopper opens a dispute:

  • We take the disputed amount from your balance at once, as a chargeback row in the ledger, and the dispute fee as a second chargeback row when there is one. The rows are available straight away, so they come off your next payout.
  • We put the order on risk hold with the reason dispute_opened, so nothing more ships until you release it. An order that is cancelled, already held or partly shipped stays as it is.
  • We send the commerce.dispute.opened webhook.

If you win, we give the amount back as a positive chargeback row. The fee comes back too when Stripe returns it, and a PayPal fee never does. Losing or accepting the dispute keeps the rows, and so does a dispute that closes without an outcome.

A bank can ask about a payment before it disputes it. Stripe calls this an inquiry, and the dispute shows inquiry: true. An inquiry takes no money and sends no commerce.dispute.opened, though we still hold the order. You cannot accept an inquiry: answer it with evidence, or refund the order. If the bank turns it into a chargeback, inquiry becomes false, and we take the money and send commerce.dispute.opened.

Event When
commerce.dispute.opened A chargeback opens, or an inquiry becomes one.
commerce.dispute.won The dispute ends in your favour.
commerce.dispute.lost The dispute ends in the shopper's favour, or you accept it.

Each event's data holds the order_id, the dispute_id, the amount and the currency_code. When the provider reports a win after a loss, commerce.dispute.won follows commerce.dispute.lost and the money comes back. Receive commerce webhooks covers delivery.

GET /commerce/disputes lists the store's disputes newest first, with count for every dispute the filters match.

Filter Takes
status A status from the table below, or open for needs_response and under_review together
order_id An order's numeric id
livemode true for real payments, false for test ones
limit, offset Up to 200 a page; 50 by default
Shell
curl "https://api.coritan.com/api/v1/orgs/acme/commerce/disputes?status=needs_response&livemode=true" \
  -H "X-API-Key: $ORG_API_KEY"
Status What it means
needs_response The bank is waiting for your evidence, until the time in due_by.
under_review The bank is deciding, usually because you sent evidence.
won The dispute ended in your favour, and any money we took comes back.
lost The dispute ended in the shopper's favour.
accepted You accepted the dispute.
closed It ended without an outcome, such as an inquiry that went no further.

Each dispute has the order it is about, with the order's id, public_id and display_id, and the provider the shopper paid through: stripe or paypal. It has the amount and fee_amount in minor units of its currency_code, the bank's reason, and an outcome once there is one. can_submit says whether you can still send evidence, and can_accept whether you can accept the dispute. ledger_booked_at and ledger_reversed_at say when we took the money and when we gave it back.

GET /commerce/disputes/{dispute_id} answers the dispute with its evidence, the provider's last 20 events about it, the submission once evidence has gone, and last_error when the provider refused a request.

We assemble the evidence from the order when the dispute opens. While the dispute waits for evidence, each read assembles it again and keeps your changes, so it shows what sending it now would send. Reading changes nothing.

evidence field What it holds
fields The text we would send, by field name.
edited The fields you set yourself.
urls The links you added.
facts What we read from the order: its items, invoice, credit notes, refunds and shipments, the customer and their addresses, the terms they accepted, your policies, what the shopper was told, and the payment.
missing What the order lacks that would make the evidence stronger.
assembled_at, updated_at When we last assembled the evidence, and when you last changed it.

From the order, we fill in these fields:

  • customer_email_address, customer_name and customer_purchase_ip.
  • billing_address and shipping_address. Without a billing address, billing_address repeats the shipping address.
  • shipping_carrier, shipping_tracking_number, and shipping_date, the day the first shipment left.
  • product_description, with each item written as 2 x Logo T-shirt (SKU TSHIRT-M).
  • refund_policy_disclosure: when the shopper accepted which version of your terms, with your terms_url, then your refund policy and your returns window.
  • uncategorized_text: the order and its invoice, each shipment, each refund with its credit note, your shipping policy, and the updates the shopper could see on their order.
missing What it means
shipping_tracking_number The order ships, and no shipment has a tracking number.
delivery The order ships, and no shipment is marked delivered.
terms The order has no record that the shopper accepted your terms.
refund_policy The store has neither a refund policy nor a returns window.
customer_purchase_ip The order has no IP address for the shopper.

Tracking and delivery come from the order's fulfilments, as Ship an order yourself describes. A dispute that waits for evidence picks up a change to them the next time you read it.

Add your policies to the evidence

Section titled Add your policies to the evidence

The evidence quotes three store settings. Set them with PATCH /commerce/store, which takes commerce.store:write or the Admin role:

Setting What the evidence does with it
refund_policy Quotes it in refund_policy_disclosure. It is text of up to 5,000 characters.
shipping_policy Quotes it in uncategorized_text. It is text of up to 5,000 characters.
returns_window_days Says in refund_policy_disclosure that you accept returns within that many days.
Shell
curl -X PATCH "https://api.coritan.com/api/v1/orgs/acme/commerce/store" \
  -H "X-API-Key: $ORG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"settings": {"refund_policy": "Return any unused item within 30 days of delivery for a full refund.", "shipping_policy": "Orders leave our Leeds warehouse within 2 working days.", "returns_window_days": 30}}'

A longer policy answers 422. Set terms_url as well, so that shoppers accept your terms before they pay, as Change the store's settings describes. A dispute that waits for evidence picks up new policies the next time you read it.

  1. Read the dispute. Check its evidence.fields and evidence.missing, and that can_submit is true.

  2. Correct or add to the text with PUT /commerce/disputes/{dispute_id}/evidence. Name only the fields you change, and send urls to replace the links:

    Shell
    curl -X PUT "https://api.coritan.com/api/v1/orgs/acme/commerce/disputes/31/evidence" \
      -H "X-API-Key: $ORG_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "fields": {
          "shipping_tracking_number": "00340434161234567890",
          "cancellation_rebuttal": "The shopper did not ask to cancel before the order shipped."
        },
        "urls": ["https://shop.example.com/proof/1043-delivery.pdf"]
      }'
    

    Text sets a field, and an empty string leaves it out. null gives the field back to the text we assemble from the order. The fields you do not name keep their text, and leaving out urls keeps the links. The answer is the dispute in full.

  3. Send the evidence with POST /commerce/disputes/{dispute_id}/submit:

    Shell
    curl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/disputes/31/submit" \
      -H "X-API-Key: $ORG_API_KEY" \
      -H "Idempotency-Key: dispute-31-evidence"
    

    We assemble the evidence once more and send it through the payment provider. The answer is 200 with the dispute: evidence_submitted_at is set, the status moves on, usually to under_review, and submission records when the evidence went, who sent it and which fields. The order's timeline notes it too.

Caution

The bank takes one submission. Once the evidence has gone, you cannot change it or send more.

fields takes these names, which are the text fields of Stripe's dispute evidence: access_activity_log, billing_address, cancellation_policy_disclosure, cancellation_rebuttal, customer_email_address, customer_name, customer_purchase_ip, duplicate_charge_explanation, duplicate_charge_id, product_description, refund_policy_disclosure, refund_refusal_explanation, service_date, shipping_address, shipping_carrier, shipping_date, shipping_tracking_number and uncategorized_text. The evidence also has these limits, and the message of a 422 names the one you broke:

  • Each field is text of up to 20,000 characters.
  • You can add up to 20 links, each an http or https address of up to 2,000 characters. We add them to the end of uncategorized_text when we send it.
  • Everything together, links included, is up to 150,000 characters.

evidence_via says what submitting does:

evidence_via What submitting does
api Sends the evidence to the bank through the payment provider.
dashboard Records the evidence as sent, for sending in the provider's own dashboard.
unavailable Nothing. The answer is 409 with provider_unavailable.

With dashboard, the dispute stays needs_response, and submission.note says to send the evidence in the provider's dashboard before the deadline. Coritan holds the payment account, so ask support to send it there.

PayPal takes evidence as one note of up to 2,000 characters. We write uncategorized_text first, and each other field after it with its name as a label. When the note is too long, we leave out refund_policy_disclosure, cancellation_policy_disclosure, refund_refusal_explanation, billing_address, customer_name and service_date, in that order, until it fits, and say how many we left out. If it still does not fit, we cut the end of uncategorized_text, which is where the links are. For a PayPal dispute, put what matters most at the start of uncategorized_text.

Accept a dispute when you agree with the shopper, or when the order is not worth contesting. The shopper keeps the money, and the chargeback rows stay on your ledger.

Shell
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/commerce/disputes/31/accept" \
  -H "X-API-Key: $ORG_API_KEY" \
  -H "Idempotency-Key: dispute-31-accept" \
  -H "Content-Type: application/json" \
  -d '{"note": "The parcel was lost in transit, so we do not contest this."}'

The answer is 200 with the dispute, now accepted with the outcome lost, and we send commerce.dispute.lost. The note is optional and up to 2,000 characters. PayPal receives it, and Stripe takes no note. You can accept a dispute only while it is needs_response and is not an inquiry, which can_accept shows.

Warning

Accepting a dispute cannot be undone, and you cannot send evidence for it afterwards.

Sending evidence and accepting share a limit of 30 a minute for the organization, retries included. Beyond it, the answer is 429 with rate_limited and a Retry-After of 60 seconds.

Both take an optional Idempotency-Key header of up to 128 characters. A retry with the same key within 24 hours gets the first answer back, and we send nothing again. The same key sent for another dispute, or with another note, answers 409 with idempotency_key_reused. While the first request is still running, a retry answers 409 with idempotency_in_progress. A request that fails keeps no key, so a retry with the same key runs again.

Once the evidence has gone, the dispute shows evidence_submitted_at and waits in under_review for the bank. The bank's decision moves it to won or lost and sends commerce.dispute.won or commerce.dispute.lost. After a win, GET /commerce/ledger?entry_type=chargeback shows the positive rows that gave the money back.

409 with dispute_not_open
The dispute no longer waits for evidence, and the answer's status says where it is. You can change evidence, send it or accept the dispute only while it is needs_response and before evidence has gone.
409 with evidence_already_submitted
The evidence has gone, and the bank takes one submission. Wait for its decision.
409 with evidence_overdue
The bank's deadline, in the answer's due_by, has passed, and it takes no more evidence.
409 with submission_in_progress
Another request is sending this dispute's evidence or accepting it. Read the dispute again in a few minutes.
409 with provider_unavailable or accept_not_supported
We cannot reach the payment account for this dispute right now, or its provider cannot accept a dispute through the API. Ask support.
409 with dispute_is_inquiry
An inquiry cannot be accepted. Send evidence, or refund the order.
409 with order_missing
The order the dispute is about no longer exists. Ask support.
409 with idempotency_key_reused
You sent the key before for another dispute or with another note. Use a new key for each request.
422 with field set to evidence
The message says why: a field name we do not know, with the names we take in allowed; no evidence to send, because every field is empty; or more than 150,000 characters together. Give fields back to the assembled text with null, or shorten them.
429 with rate_limited
The organization sent evidence or accepted disputes more than 30 times in a minute. Wait for the time in Retry-After.
502 with payment_provider_error
The payment provider refused the evidence or the acceptance. Its reason is in the message and in the dispute's last_error. The dispute still waits, so correct what the reason names and try again.

API operations on this page

MethodPathWhat it does
GET/api/v1/orgs/{org_slug}/commerce/disputesNewest first; count is every dispute the filters match
GET/api/v1/orgs/{org_slug}/commerce/disputes/{dispute_id}The dispute, its evidence and what the provider has said about it
PUT/api/v1/orgs/{org_slug}/commerce/disputes/{dispute_id}/evidenceSet evidence text fields and links while the dispute waits for them
POST/api/v1/orgs/{org_slug}/commerce/disputes/{dispute_id}/submitSend the evidence to the bank
POST/api/v1/orgs/{org_slug}/commerce/disputes/{dispute_id}/acceptConcede the dispute: the shopper keeps the money and the debit stays