API reference
Conventions
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 (
reasonCodenext toreason_code). They say so withDeprecation: trueandLink: <https://dispute.markets/docs/api/conventions>; rel="deprecation". - Keys inside data maps keep their case:
metadata, a flow profile'svariants, an audit event'schanges, a flow snapshot and thepayloadof 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.
{
"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_idis the same value as theX-Request-Idheader. Quote it when you report a problem.- On
validation_failed,detailslists 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 afterRetry-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 withcorrelation_id=ci-4812:*. - The API answers an invalid value with
400 invalid_correlation_idand 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:
/**
* 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.
| Situation | Answer |
|---|---|
| Same key, same request, first one finished | The stored response, with Idempotent-Replayed: true. No new audit event. |
| Same key, first request still running | 409 idempotency_in_progress, retry after Retry-After |
| Same key, different method, path, query or body | 409 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 KB | 409 idempotency_response_too_large |
| The first request failed | The key is freed; the retry runs normally. |
| Malformed key | 400 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:
| Bucket | Requests / minute |
|---|---|
| All requests | 600 |
| Writes (POST, PATCH, PUT, DELETE) | 300 |
/v1/dev/* routes | 60 |
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).