Disputes
Think a charged result was wrong? Dispute it within 7 days; we decide within 48 hours against the stored result.
You pay only for results that pass their published check. A check can still be wrong: an email that was called deliverable bounces, a company record is out of date, an item was charged twice. A dispute is how you tell us. You point at the charged item, say what is wrong, and we compare your claim with the stored copy of the result. If we agree, the charge goes back to your balance and the tool's score takes the hit. This page covers who can dispute, the request, what we compare against, the decision, the 48-hour deadline, how to follow a dispute, the notifications, and the errors.
Arettic is pre-launch. Keys go to design partners first; everyone else joins the waitlist. The @arettic/sdk and @arettic/mcp packages (npm) and the arettic package (PyPI) are published at launch. Disputes need a live key: test purchases cost nothing, so there is nothing to dispute.
What can be disputed, and by whom
An item can be disputed when all of these hold:
- It was charged. Only items with a
chargedabove 0 on the receipt qualify: apassedresult, or apartialone charged pro rata. A failed check, a provider error or an expired hold cost you nothing, so there is nothing to dispute. The API answers not_disputable. - It was bought with a live key. Test keys (
sk_test_…) buy from mock tools for free and write no receipt. A test key that callsPOST /v1/disputesgets invalid_input: "Test-mode purchases are free; there's nothing to dispute". - It settled less than 7 days ago. The clock starts at the item's
settled_aton the receipt. Later than that, the API answers dispute_window_closed. - It has no dispute yet. One dispute per item, ever. A second attempt answers conflict: "This item already has a dispute".
- Your org has fewer than 100 open disputes. At 100 the API answers rate_limited until some are decided.
Four places can file one, and they all create the same dispute:
| From | How | Who |
|---|---|---|
| Agent key | POST https://api.arettic.com/v1/disputes | Any live agent of the org, for any item on the org's receipts. |
| Org API | POST https://api.arettic.com/v1/orgs/{orgId}/disputes | A signed-in member of any role, or an org key (ok_…) with write scope. A read-only org key gets forbidden. |
| Dashboard | Receipts, open the receipt, Dispute on the item's row (https://arettic.com/app/receipts/{receiptId}) | Any member. The button only shows on items that qualify. |
| MCP | The open_dispute tool | Any live agent. Same arguments as the agent key request. |
The request
Name the item either by its receipt and position, or by its execution. Then say why.
| Field | Type | Required | Notes |
|---|---|---|---|
| receipt_id | UUID string | unless execution_id is sent | The receipt the item is on, from the execute response or the receipt. |
| item_index | integer, 0 or more | no | The item's position on the receipt (items[].index). Default 0, which is the only item of a single-input purchase. |
| execution_id | UUID string | instead of receipt_id | The item's execution_id from the execute response or the receipt. When it is sent, receipt_id and item_index are ignored. |
| reason | string | yes | One of the five reasons below. |
| evidence | string, up to 4,000 characters | when reason is other | What is wrong, in your words. Trimmed; anything past 4,000 characters is dropped. The reviewer reads it, and it is shown on the dispute. |
| reason | Meaning | Label in the dashboard |
|---|---|---|
| wrong_result | The result passed its check but is not true. | The result is wrong |
| invalid_result | The result does not work: the email bounced, the number is dead. | The result doesn't work |
| stale_result | The result was true once, not now. | The result is out of date |
| duplicate_charge | You were charged twice for the same thing. | Charged twice |
| other | Anything else. evidence is required. | Something else |
If the item was served by a fallback tool (the receipt's item has fallback_of), the dispute lands on the execution that was charged, and the dispute's tool_id names that tool. Other agents' purchases count too: any agent of the org can dispute any item on the org's receipts. Another org's receipt answers not_found, the same as an item_index that does not exist.
Opening a dispute
Item 1 of a two-email purchase was called deliverable and bounced. The same call in curl, TypeScript, Python and over MCP:
curl https://api.arettic.com/v1/disputes \
-H "Authorization: Bearer $ARETTIC_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"receipt_id": "67a1fb46-c554-4cf6-b4e2-df1dbdf0e936",
"item_index": 1,
"reason": "invalid_result",
"evidence": "Bounced when we sent to it"
}'curl https://api.arettic.com/v1/disputes \
-H "Authorization: Bearer $ARETTIC_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "execution_id": "c02576cf-b3ce-4b0f-a30c-6e8aad4328ce", "reason": "invalid_result" }'import { Arettic, AretticApiError } from "@arettic/sdk";
const arettic = new Arettic({ apiKey: process.env.ARETTIC_API_KEY });
try {
const dispute = await arettic.openDispute({
receipt_id: "67a1fb46-c554-4cf6-b4e2-df1dbdf0e936",
item_index: 1, // or { execution_id: "…" } instead of receipt_id + item_index
reason: "invalid_result",
evidence: "Bounced when we sent to it",
});
console.log(dispute.dispute_id, dispute.status, dispute.decide_by); // "open", 48 hours from now
} catch (err) {
if (err instanceof AretticApiError) {
// "not_disputable", "dispute_window_closed", "conflict", "not_found", "invalid_input"…
console.error(err.status, err.code, err.message);
}
throw err;
}from arettic import Arettic, AretticApiError
client = Arettic() # reads ARETTIC_API_KEY (and ARETTIC_API_URL, if set)
try:
dispute = client.open_dispute(
receipt_id="67a1fb46-c554-4cf6-b4e2-df1dbdf0e936",
item_index=1, # or execution_id="…" instead of receipt_id + item_index
reason="invalid_result",
evidence="Bounced when we sent to it",
)
except AretticApiError as err:
# "not_disputable", "dispute_window_closed", "conflict", "not_found", "invalid_input"...
print(err.status, err.code, err.message)
raise
print(dispute["dispute_id"], dispute["status"], dispute["decide_by"]) # "open", 48 hours from now{
"name": "open_dispute",
"arguments": {
"receipt_id": "67a1fb46-c554-4cf6-b4e2-df1dbdf0e936",
"item_index": 1,
"reason": "invalid_result",
"evidence": "Bounced when we sent to it"
}
}Over MCP, open_dispute takes receipt_id and item_index only (no execution_id); item_index defaults to 0. The tool result is the same JSON as the REST response, as text content. The API answers 201 Created with the dispute:
{
"dispute_id": "a3d9e2b1-5c7f-4e08-9b6d-2f1c8a7e4d30",
"status": "open",
"receipt_id": "67a1fb46-c554-4cf6-b4e2-df1dbdf0e936",
"item_index": 1,
"execution_id": "c02576cf-b3ce-4b0f-a30c-6e8aad4328ce",
"tool_id": "hunter-verify-email",
"reason": "invalid_result",
"evidence": "Bounced when we sent to it",
"charged": { "credits": "7", "usd": "0.007" },
"refunded": { "credits": "0", "usd": "0.000" },
"opened_at": "2026-09-29T10:12:03.417Z",
"decide_by": "2026-10-01T10:12:03.417Z",
"decided_at": null
}From the org API the request body is the same, at POST /v1/orgs/{orgId}/disputes. With the SDKs: new AretticOrg({ orgId }).disputes.create({ … }) and AretticOrg(org_id=…).disputes.create(…). See Org API.
The dispute object
Every dispute endpoint returns the same object. Money is { "credits": "7", "usd": "0.007" }, as everywhere else in the API. Times are ISO 8601 in UTC.
| Field | Meaning |
|---|---|
| dispute_id | The dispute's UUID. Use it with GET /v1/disputes/{id}. |
| status | open until we decide, then upheld (refunded) or rejected (the charge stands). It never changes again after that. |
| receipt_id | The receipt the item is on. |
| item_index | The item's position on that receipt. |
| execution_id | The charged execution behind the item. After a fallback, the one that served it. |
| tool_id | The tool that produced the disputed result. |
| reason | The reason you gave. |
| evidence | Your evidence, or null. |
| charged | What the item cost you. This is the most an upheld dispute refunds. |
| refunded | What has been refunded for this dispute: 0 while open or rejected, the item's charge once upheld. |
| opened_at | When the dispute was filed. |
| decide_by | 48 hours after opened_at. Our deadline; past it the dispute is upheld automatically. |
| decided_at | When it was decided, or null while open. |
| decision_note | Present once decided and a note was written. Every rejection has one, because we must say why. An automatic uphold says "Upheld automatically: not decided within 48 hours". |
What we compare against
Every live purchase's input and result are stored encrypted with your org's own key, for 7 days, then hard-deleted. That copy is what a dispute is decided against. Opening a dispute puts a hold on the item's copy so it outlives the 7 days: it is kept until the dispute is decided, and at most 48 hours beyond the usual 7 days. Once decided, the hold lifts and the normal retention applies again.
The reviewer sees your reason and evidence, the stored input and result, the result's hash, the check and its version, and the reason the check recorded. Every look at a stored copy is logged with who looked, which dispute it was for, and whether the copy was still there. The copy is not shared with the provider or anyone outside Arettic. If you asked for your org's data to be deleted before the dispute was reviewed, the copy is gone, and the reviewer is told so instead of seeing a result. Data handling has the retention rules in full.
The decision
| status | What happens |
|---|---|
| upheld | The item's charge goes back to your balance, to the same paid or trial credits it was taken from, as a refund entry on the ledger that references the dispute. The dispute's refunded becomes the item's charged, the receipt's refunded total grows by the same amount, and the item's dispute.refunded on the receipt shows it. The upheld dispute counts against the tool's score. |
| rejected | The charge stands. The reviewer has to write a decision_note saying why, and you see it on the dispute, in the dashboard and in the email. Nothing changes on the receipt except the item's dispute.status. |
A dispute is decided once. Receipts never change; a refund is added on top, so the receipt's charged still says what you paid at the time and refunded says what came back. If the same item was somehow refunded already, the uphold is refused rather than paid twice.
48 hours, then automatic uphold
We decide within 48 hours of opened_at; decide_by is that moment. An hourly job upholds every open dispute past its decide_by, with the note "Upheld automatically: not decided within 48 hours", and refunds it like any other uphold. You never wait on us longer than 48 hours, and you never need to chase a dispute: if nobody looked at it, you get the refund.
Following a dispute
A dispute shows up in four places as soon as it is filed:
GET https://api.arettic.com/v1/disputes/{id}with any agent key of the org: the dispute object above.GET https://api.arettic.com/v1/orgs/{orgId}/disputeswith a session or an org key:{ "disputes": [ … ] }, open ones first, newest first, up to 500. Add?status=open,upheldorrejectedto filter. This list is not available to agent keys.- The receipt: the item gets a
disputefield withdispute_id,statusandrefunded, onGET /v1/receipts/{id}and the org receipt endpoints. Items without a dispute have nodisputefield. - The dashboard:
https://arettic.com/app/disputeslists every dispute with its reason, charge, status, deadline, decision time, refund and decision note, with filters for open, upheld and rejected.
# One dispute, with an agent key curl https://api.arettic.com/v1/disputes/a3d9e2b1-5c7f-4e08-9b6d-2f1c8a7e4d30 \ -H "Authorization: Bearer $ARETTIC_API_KEY" # The org's open disputes, with an org key curl "https://api.arettic.com/v1/orgs/0f4e7c2a-91b3-4d6e-8a5f-3c2b1d0e9f87/disputes?status=open" \ -H "Authorization: Bearer $ARETTIC_ORG_KEY"
import { Arettic, AretticOrg } from "@arettic/sdk";
const arettic = new Arettic({ apiKey: process.env.ARETTIC_API_KEY });
const dispute = await arettic.dispute("a3d9e2b1-5c7f-4e08-9b6d-2f1c8a7e4d30");
if (dispute.status === "upheld") console.log("refunded", dispute.refunded.credits);
if (dispute.status === "rejected") console.log("kept, because:", dispute.decision_note);
// The org's list needs an org key (ok_…), not an agent key.
const org = new AretticOrg({ apiKey: process.env.ARETTIC_ORG_KEY, orgId: "0f4e7c2a-91b3-4d6e-8a5f-3c2b1d0e9f87" });
const open = await org.disputes.list({ status: "open" });
console.log(open.length, "waiting for a decision");import os
from arettic import Arettic, AretticOrg
client = Arettic()
dispute = client.dispute("a3d9e2b1-5c7f-4e08-9b6d-2f1c8a7e4d30")
if dispute["status"] == "upheld":
print("refunded", dispute["refunded"]["credits"])
elif dispute["status"] == "rejected":
print("kept, because:", dispute.get("decision_note"))
# The org's list needs an org key (ok_...), not an agent key.
org = AretticOrg(api_key=os.environ["ARETTIC_ORG_KEY"], org_id="0f4e7c2a-91b3-4d6e-8a5f-3c2b1d0e9f87")
open_disputes = org.disputes.list(status="open")["disputes"]
print(len(open_disputes), "waiting for a decision"){
"dispute_id": "a3d9e2b1-5c7f-4e08-9b6d-2f1c8a7e4d30",
"status": "upheld",
"receipt_id": "67a1fb46-c554-4cf6-b4e2-df1dbdf0e936",
"item_index": 1,
"execution_id": "c02576cf-b3ce-4b0f-a30c-6e8aad4328ce",
"tool_id": "hunter-verify-email",
"reason": "invalid_result",
"evidence": "Bounced when we sent to it",
"charged": { "credits": "7", "usd": "0.007" },
"refunded": { "credits": "7", "usd": "0.007" },
"opened_at": "2026-09-29T10:12:03.417Z",
"decide_by": "2026-10-01T10:12:03.417Z",
"decided_at": "2026-09-30T08:40:19.052Z"
}There is no MCP tool for reading a dispute. An agent on MCP can call get_receipt and read the item's dispute field instead.
When it is decided
A decision, by a person or by the 48-hour job, sends one dispute.decided event. It goes to every webhook of the org that subscribes to it (or to all events), signed with the endpoint's secret, and as an email to the org's owners. Nothing is sent when a dispute is opened.
{
"id": "5b2c8e1f-7a3d-4f9e-b6c0-1d4a2e8f7c93",
"type": "dispute.decided",
"created_at": "2026-09-30T08:40:19.052Z",
"data": {
"dispute_id": "a3d9e2b1-5c7f-4e08-9b6d-2f1c8a7e4d30",
"receipt_id": "67a1fb46-c554-4cf6-b4e2-df1dbdf0e936",
"item_index": 1,
"decision": "upheld",
"refunded_credits": "7",
"note": null
}
}decision is upheld or rejected; refunded_credits is a whole number of credits as a string, "0" on a rejection; note is the decision note or null. The email's subject is "Your dispute was upheld" or "Your dispute was rejected", and its body names the receipt and item and, on an uphold, the credits refunded. Poll GET /v1/disputes/{id} if you would rather not run a webhook: the status changes at the same moment.
What an upheld dispute does to a tool's score
Disputes are the D input of every tool's score. For each tool and region, at the weekly recompute, D is the number of disputes upheld in the last 28 days divided by the number of live results that passed (or partially passed) in the last 28 days. The score loses min(20, 500 × D) points: one upheld dispute per 100 passed results costs 5 points, and one per 25 or worse costs the maximum 20. Rejected disputes do not count. A score moves at most 10 points a week, so a wave of upheld disputes shows over a few weeks unless the tool is paused, and D is published next to the score with the other inputs.
This is why we review disputes rather than refund on request: an upheld dispute is a signal to every buyer of that tool, so it has to be right.
Test mode
A test key cannot dispute. Test purchases run against mock providers, cost nothing and write no receipt, so POST /v1/disputes and the open_dispute tool answer 400 invalid_input with "Test-mode purchases are free; there's nothing to dispute". Use a live key and a charged item to try the flow end to end. See Test mode.
Errors
Every refusal is { "error": { "code", "message", "doc_url", "retryable" } }, and the SDKs raise it as AretticApiError with the same fields. The codes a dispute request can get:
| Code | HTTP | When |
|---|---|---|
| invalid_input | 400 | A test key filed; reason is not one of the five; reason is other without evidence; neither a valid execution_id nor a valid receipt_id with a whole-number item_index was sent. The message names the problem. |
| not_found | 404 | No item with that receipt_id and item_index (or that execution_id) on your org's receipts, or, on a GET, no dispute with that id in your org. |
| not_disputable | 409 | The item was not charged: nothing to refund. |
| dispute_window_closed | 409 | The item settled more than 7 days ago. |
| conflict | 409 | The item already has a dispute. |
| rate_limited | 429 | Your org already has 100 open disputes. Wait for decisions. |
| unauthenticated | 401 | No key, or a revoked one. |
| forbidden | 403 | A read-only org key tried to file, or an org key was used on another org's path. |
{
"error": {
"code": "dispute_window_closed",
"message": "Disputes must be opened within 7 days of the purchase",
"doc_url": "https://arettic.com/docs/errors#dispute_window_closed",
"retryable": false
}
}See also
- Receipts: where
receipt_id,item_index,execution_id,settled_atand the item'sdisputefield live. - Scores: the formula, and D next to the other five inputs.
- Webhooks: subscribing to
dispute.decided, the signature and retries. - Data handling: the stored copy, its 7 days, and the hold a dispute puts on it.
- Error codes: every code, with its status and fix.