Buying results

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:

Four places can file one, and they all create the same dispute:

Ways to open a dispute
FromHowWho
Agent keyPOST https://api.arettic.com/v1/disputesAny live agent of the org, for any item on the org's receipts.
Org APIPOST https://api.arettic.com/v1/orgs/{orgId}/disputesA signed-in member of any role, or an org key (ok_…) with write scope. A read-only org key gets forbidden.
DashboardReceipts, 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.
MCPThe open_dispute toolAny 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.

Fields of POST /v1/disputes
FieldTypeRequiredNotes
receipt_idUUID stringunless execution_id is sentThe receipt the item is on, from the execute response or the receipt.
item_indexinteger, 0 or morenoThe item's position on the receipt (items[].index). Default 0, which is the only item of a single-input purchase.
execution_idUUID stringinstead of receipt_idThe item's execution_id from the execute response or the receipt. When it is sent, receipt_id and item_index are ignored.
reasonstringyesOne of the five reasons below.
evidencestring, up to 4,000 characterswhen reason is otherWhat is wrong, in your words. Trimmed; anything past 4,000 characters is dropped. The reviewer reads it, and it is shown on the dispute.
Reasons
reasonMeaningLabel in the dashboard
wrong_resultThe result passed its check but is not true.The result is wrong
invalid_resultThe result does not work: the email bounced, the number is dead.The result doesn't work
stale_resultThe result was true once, not now.The result is out of date
duplicate_chargeYou were charged twice for the same thing.Charged twice
otherAnything 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
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, by execution_id
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" }'
TypeScript
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;
}
Python
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
MCP tool call
{
  "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:

Response (example)
{
  "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.

Fields of a dispute
FieldMeaning
dispute_idThe dispute's UUID. Use it with GET /v1/disputes/{id}.
statusopen until we decide, then upheld (refunded) or rejected (the charge stands). It never changes again after that.
receipt_idThe receipt the item is on.
item_indexThe item's position on that receipt.
execution_idThe charged execution behind the item. After a fallback, the one that served it.
tool_idThe tool that produced the disputed result.
reasonThe reason you gave.
evidenceYour evidence, or null.
chargedWhat the item cost you. This is the most an upheld dispute refunds.
refundedWhat has been refunded for this dispute: 0 while open or rejected, the item's charge once upheld.
opened_atWhen the dispute was filed.
decide_by48 hours after opened_at. Our deadline; past it the dispute is upheld automatically.
decided_atWhen it was decided, or null while open.
decision_notePresent 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

The two outcomes
statusWhat happens
upheldThe 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.
rejectedThe 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:

curl
# 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"
TypeScript
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");
Python
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")
Response (example)
{
  "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.

Webhook body (example)
{
  "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:

Error codes on the dispute endpoints
CodeHTTPWhen
invalid_input400A 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_found404No 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_disputable409The item was not charged: nothing to refund.
dispute_window_closed409The item settled more than 7 days ago.
conflict409The item already has a dispute.
rate_limited429Your org already has 100 open disputes. Wait for decisions.
unauthenticated401No key, or a revoked one.
forbidden403A read-only org key tried to file, or an org key was used on another org's path.
Response (example)
{
  "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

Updated 2026-09-29 · This page as Markdown · JSON