API reference

Conventions

The rules every route follows: how names are spelled, how errors look, which headers you send and get back, how retries stay safe, how fast a key may go and how lists page.

Names are snake_case

Every name on the wire is snake case: request and response bodies at every level, query parameters, webhook payloads and errors (created_at, reason_code, page_size, status_code). The OpenAPI document lists these names only.

The original routes (disputes, transactions, metrics, reports, webhooks, playbooks, synthetic accounts and the dev panel) used camel case until October 2026. So that clients written against them keep working, for now:

  • Requests may still use the old camel case names in bodies and query strings (pageSize, cardBrand, fromDate, …). When a request sends both spellings, the snake case one wins.
  • Responses and webhook payloads of those routes also carry each such field under its old camel case name, with the same value (reasonCode next to reason_code). They say so with Deprecation: true and Link: <https://dispute.markets/docs/api/conventions>; rel="deprecation".
  • Keys inside data maps keep their case: metadata, a flow profile's variants, an audit event's changes, a flow snapshot and the payload of a test webhook are your data or ours, not field names.

Errors

Every 4xx and 5xx response has the same JSON body. Branch on code, which is stable and always lowercase snake case; the message is for people and may change.

400 Bad Request
{
  "status_code": 400,
  "message": ["identifier should not be empty"],
  "error": "Bad Request",
  "code": "validation_failed",
  "request_id": "req_01JBQ9Z6X6W3N2Q4T8V0M5K7RD",
  "details": [{ "field": "identifier", "constraints": ["isNotEmpty"] }]
}
  • request_id is the same value as the X-Request-Id header. Quote it when you report a problem.
  • On validation_failed, details lists each failed field and the names of the checks it failed. The value you sent is never echoed.
  • 5xx messages are fixed per status (internal_error, service_unavailable, …); retry 503s after Retry-After.

X-Request-Id

Every response carries X-Request-Id: req_<ulid>, and so does every error body and every audit event the request wrote. The API makes its own id; one you send is ignored (only the portal may pass the browser's id through). To find what one request did, list the trail with request_id=req_….

X-Correlation-Id

Your own label for a group of requests, typically one per test. Every audit event a request causes carries it, including later events from jobs, lifecycle runs and webhook deliveries the request started, so a test can ask the trail for exactly its own events.

  • 1 to 128 characters of A-Z a-z 0-9 . _ : / -. Use a run prefix plus a test id (ci-4812:accept-dispute) so a whole run can be found with correlation_id=ci-4812:*.
  • The API answers an invalid value with 400 invalid_correlation_id and does nothing else.
  • The portal never fails a page load over it: it drops an invalid value, adds X-Correlation-Id-Rejected: <reason> to its response and logs a console warning. Browser tests set it as a cookie, see the E2E guide.
  • A valid value is echoed back in the response's X-Correlation-Id.

The example checks the value locally before sending it:

examples/e2e-playwright/src/correlation.ts
/**
 * The X-Correlation-Id contract. The same literal dispute-api enforces
 * (a dispute-api spec asserts the two match): 1-128 characters of
 * `A-Z a-z 0-9 . _ : / -`. The fixture checks every id against it before
 * use, so a typo fails at once instead of timing out on a wait.
 */
export const CORRELATION_ID_PATTERN = /^[A-Za-z0-9._:/-]{1,128}$/;

/** The header the API reads, and the cookie the portal forwards as that header. */
export const CORRELATION_ID_HEADER = 'X-Correlation-Id';
export const CORRELATION_COOKIE = 'dm_correlation_id';

export function assertCorrelationId(value: string): string {
  if (!CORRELATION_ID_PATTERN.test(value)) {
    throw new Error(
      `Invalid correlation id ${JSON.stringify(value)}: use 1-128 characters of A-Z a-z 0-9 . _ : / -`,
    );
  }
  return value;
}

X-DM-Scopes

Narrows an API key for one request to the scopes listed (comma or space separated). It only ever removes scopes, never adds one, so a single key can test a refused path:

curl -s -X POST https://api.dispute.markets/v1/disputes/dsp_…/challenge \
  -H "Authorization: Bearer $DM_API_KEY" \
  -H "X-DM-Scopes: dispute.read" -H "Content-Type: application/json" -d '{}'
# 403 {"code":"missing_scope", …}, and a dispute.challenge event with outcome "denied"

An empty header narrows to nothing; a name that is not a scope is 400 invalid_scopes.

Idempotency-Key

Every write an API key can call (the POST, PATCH and DELETE routes under disputes, evidence, reports, playbooks, webhooks and dev) accepts an optional Idempotency-Key: 8 to 255 characters of A-Z a-z 0-9 _ -. Send the same value when you retry and, for 24 hours, the request is not executed twice. Sign-in, MFA, account and team routes ignore it.

SituationAnswer
Same key, same request, first one finishedThe stored response, with Idempotent-Replayed: true. No new audit event.
Same key, first request still running409 idempotency_in_progress, retry after Retry-After
Same key, different method, path, query or body409 idempotency_key_reuse
Response held a secret or signed URL (create webhook, evidence upload URL)Never stored: 409 idempotency_secret_not_replayable with details.resource_id of what the first request created.
Response over 64 KB409 idempotency_response_too_large
The first request failedThe key is freed; the retry runs normally.
Malformed key400 idempotency_key_invalid

Keys are scoped to the caller: one API key cannot replay another's.

Rate limits: a worker budget

Limits are per API key, per API server, per minute:

BucketRequests / minute
All requests600
Writes (POST, PATCH, PUT, DELETE)300
/v1/dev/* routes60

Responses carry RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset for the tightest bucket; over it you get 429 rate_limited with Retry-After in seconds. Requests spread over servers, so the real ceiling is approximate.

Pagination

Cursor lists (audit events and newer routes)

GET /v1/audit-events?limit=50&cursor=…

{ "data": [ … ], "pagination": { "next_cursor": "…", "has_more": true } }

limit is 1 to 200 (default 50). Pass next_cursor back as cursor with the same filters and order until has_more is false; a cursor used with other filters is 400 invalid_cursor. Fields of these routes are snake case.

Page lists (transactions, disputes, reports and other original routes)

GET /v1/disputes?page=2&page_size=50

{ "items": [ … ], "total": 1234, "page": 2, "page_size": 50 }

page_size is 1 to 200 (default 50). These routes keep their original page shape.

Ids

Resources are addressed by prefixed display ids (dsp_… disputes, gj_… generate jobs, wh_… webhooks, whd_… webhook deliveries, whs_… dev webhook sinks, sa_… synthetic accounts, tak_… API keys, aud_… audit events). Audit events never contain internal UUIDs.

OpenAPI

The full contract, with every route, its required scope, its error codes and the AuditEvent schema, is at https://api.dispute.markets/v1/docs-json. The example suite generates its types from it (npm run types).