# 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.

Source: https://www.coritan.com/docs/organizations/storefront/commerce-disputes/

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](/docs/organizations/billing/disputes/). The routes are under `https://api.coritan.com/api/v1/orgs/{org_slug}/commerce/disputes`, and the API reference lists them under [Commerce](/docs/api/reference/organizations/commerce/commerce/).

## Before you begin

- Commerce must be on for your organization, as [Sell with the Commerce API](/docs/organizations/storefront/commerce-api/#before-you-begin) 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](#add-your-policies-to-the-evidence) shows how.

## 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](/docs/organizations/storefront/commerce-api/#read-the-balance-and-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](/docs/organizations/storefront/commerce-payouts/).
- We put the order on [risk hold](/docs/organizations/storefront/commerce-api/#release-a-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](/docs/organizations/storefront/commerce-api/#receive-commerce-webhooks) covers delivery.

## Find disputes

`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 |

```bash
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.

## Read the evidence

`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](/docs/organizations/storefront/commerce-api/#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

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. |

```bash
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](/docs/organizations/storefront/commerce-api/#change-the-store-s-settings) describes. A dispute that waits for evidence picks up new policies the next time you read it.

## Send evidence

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:

   ```bash
   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`:

   ```bash
   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](https://www.coritan.com/dashboard/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

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.

```bash
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.

## Limits and retries

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.

## Result

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.

## Troubleshooting

`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](https://www.coritan.com/dashboard/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](https://www.coritan.com/dashboard/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.

## Related

- [Sell with the Commerce API](/docs/organizations/storefront/commerce-api/)
- [Get store payouts and tax reports](/docs/organizations/storefront/commerce-payouts/)
- [Follow payment disputes](/docs/organizations/billing/disputes/)
- [Receive organization webhooks](/docs/organizations/webhooks/)

## API

- `GET /api/v1/orgs/{org_slug}/commerce/disputes`: Newest first; count is every dispute the filters match (https://www.coritan.com/docs/api/reference/organizations/commerce/commerce/#op-get-api-v1-orgs-org-slug-commerce-disputes)
- `GET /api/v1/orgs/{org_slug}/commerce/disputes/{dispute_id}`: The dispute, its evidence and what the provider has said about it (https://www.coritan.com/docs/api/reference/organizations/commerce/commerce/#op-get-api-v1-orgs-org-slug-commerce-disputes-dispute-id)
- `PUT /api/v1/orgs/{org_slug}/commerce/disputes/{dispute_id}/evidence`: Set evidence text fields and links while the dispute waits for them (https://www.coritan.com/docs/api/reference/organizations/commerce/commerce/#op-put-api-v1-orgs-org-slug-commerce-disputes-dispute-id-evidence)
- `POST /api/v1/orgs/{org_slug}/commerce/disputes/{dispute_id}/submit`: Send the evidence to the bank (https://www.coritan.com/docs/api/reference/organizations/commerce/commerce/#op-post-api-v1-orgs-org-slug-commerce-disputes-dispute-id-submit)
- `POST /api/v1/orgs/{org_slug}/commerce/disputes/{dispute_id}/accept`: Concede the dispute: the shopper keeps the money and the debit stays (https://www.coritan.com/docs/api/reference/organizations/commerce/commerce/#op-post-api-v1-orgs-org-slug-commerce-disputes-dispute-id-accept)
