API reference

API overview and keys

Everything the portal does goes through one HTTP API at https://api.dispute.markets/v1. With an API key your scripts and end-to-end tests call it directly, and every action anyone takes in your workspace lands in an audit trail that your tests can read back.

In these docs

The machine-readable contract is the OpenAPI document at /v1/docs-json (browsable at /v1/docs). Generate your client types from it rather than writing them by hand.

Create a key

  1. Sign in to the portal as an owner of the workspace and open Settings > API keys. Only owners manage keys, and a key works only in the workspace it was created in (one key per environment; see Workspaces).
  2. Name the key after where it will live (github-actions-e2e), choose its scopes and its expiry, and create it. If you have not verified a second factor in the last 15 minutes, the portal asks for a code from your authenticator app, an e-mailed code or a recovery code first. The same applies to rotating a key. An owner without two-factor authentication adds it under Security first.
  3. Copy the key. It is shown once; we store only a hash. Put it in your CI secret store, never in a repository.

Send it as a bearer token on every request:

Check a key
curl -s https://api.dispute.markets/v1/whoami \
  -H "Authorization: Bearer $DM_API_KEY"

GET /v1/whoami needs no scope. It answers with the workspace, the actor your requests are recorded as (the key itself, tak_…), the permissions the key holds right now and its expiry.

Scopes and the E2E testing preset

A key carries a set of scopes, each one a portal permission. On every request the key holds the scopes it was given and that its creator still holds, so a key can never do more than the person who last vouched for it.

The E2E testing preset selects every scope a key may carry, which is what the example suite needs. The same list ships with the example:

examples/e2e-playwright/src/scopes.ts
/**
 * The scopes of the portal's "E2E testing" preset (Settings > API keys):
 * everything a key may carry. One key with these runs every spec here.
 * A dispute-api spec asserts this list equals the preset.
 */
export const E2E_SCOPES = [
  'transaction.read',
  'dispute.read',
  'dispute.write',
  'evidence.read',
  'evidence.write',
  'report.read',
  'report.write',
  'playbook.read',
  'playbook.write',
  'webhook.read',
  'webhook.write',
  'dev.generate',
  'dev.advance_lifecycle',
  'audit.read',
  'flow.read',
  'flow.write',
] as const;

export type Scope = (typeof E2E_SCOPES)[number];

Some things no key can do, whatever its scopes: manage people, invites or synthetic accounts (/v1/team/*), create, switch, leave or delete workspaces (/v1/workspaces, /v1/me/workspaces), manage keys (/v1/api-keys), sign in or change credentials (/v1/auth/*, /v1/me/*), or wipe the workspace (POST /v1/dev/reset). Those routes answer 401 api_key_not_allowed to a key. Every other route says which scope it needs in the OpenAPI document; without it a key gets 403 missing_scope.

To test a refusal without minting a second key, narrow the key for one request with X-DM-Scopes (see Conventions).

Expiry

Every key expires: after 7, 30, 90 (the default) or 365 days. There is no “never”. An expired key answers 401 invalid_api_key. Create the replacement before the old one runs out, or rotate it.

Revoke a leaked key; rotate a healthy one

ActionOld secretUse it when
RevokeRefused within about 30 seconds.The key leaked, or nothing uses it any more.
RotateKeeps working for the grace period, then refused.Routine replacement: deploy the new secret, then let the old one lapse.

Revocation takes up to about 30 seconds. Each API server remembers a verified key for at most 30 seconds, so after a revoke a request can still succeed on a server that checked the key just before. Within 30 seconds every server refuses it with 401 invalid_api_key. The example's 07-revoked-key spec measures exactly this. A revoke through dispute.markets keeps this bound even while our authentication provider is degraded. A key removed outside dispute.markets (for example when its creator leaves your team) during such an incident can keep working for up to about 90 seconds.

Revoking also stops what the key set up: webhooks and playbooks a key created are disabled once that key is revoked or expires.

When the creator leaves

A key lives only as long as its creator's membership. When the person who created (or last rotated) a key leaves the workspace or is removed, the key stops working on its next call; the trail records it as api_key.deactivate with reason: member_removed. A key never does more than its creator's current role, so demoting the creator narrows the key at once. If your CI depends on a key, have a long-standing owner create or rotate it.

Key format

pctk_live_<43 random characters>_<6-character checksum>

The last six characters are a CRC32 checksum of the random part. It lets the API refuse a mistyped or truncated key instantly and lets secret scanners recognise our keys. The checksum is not a secret and not a security control: anyone can compute it, so a well-formed key proves nothing until the API has verified it.

Errors you may see

StatuscodeMeaning
401invalid_api_keyUnknown, mistyped, expired or revoked key.
401api_key_not_allowedThis route never accepts keys (team, keys, auth, me, reset).
401account_revokedA token of a person or synthetic account that is no longer a member of the workspace the token acts in. A changed role applies from the next request too.
401session_revokedA synthetic account’s token issued before the account was archived, its password or credentials were rotated, or the owner revoked its sessions. Access ends on the next request, not when the token expires. Sign in again.
401session_expiredA synthetic account’s token issued before an expire-sessions call, or older than the session.max_age flow variant allows. Sign in again.
403missing_scopeThe key (or its X-DM-Scopes narrowing) lacks the scope this route needs.
403already_a_memberA synthetic account called POST /v1/auth/bootstrap. Synthetic accounts cannot create a workspace of their own; the attempt is recorded as auth.bootstrap denied in the workspace they belong to. People create workspaces with POST /v1/workspaces.
409workspace_mismatchX-DM-Workspace names another workspace than the key’s own. A key always acts in the workspace it was created in.
400invalid_scopesX-DM-Scopes names something that is not a scope.
429rate_limitedOver the key’s budget; retry after Retry-After seconds.

Changelog

2026-10-31: colleagues, several owners and one workspace per environment

Owners can invite colleagues by e-mail and give each one a role (Owner, Full access, Disputes, Reports or View only). A workspace can have several owners; the last one cannot leave or be removed. Removing someone ends their access to that workspace on their next request, and the keys they created stop working there. Synthetic accounts are unchanged and stay on their own tab of the Team page. Every person can also create workspaces of their own (up to 10), for example one each for Development, Staging and Production, and switch between them; the environment label is now shown on every page and in every destructive confirm. API keys, webhooks and playbooks stay in their own workspace. New routes under /v1/team/members, /v1/team/invites, /v1/me/invites, /v1/me/workspaces and /v1/workspaces, an optional X-DM-Workspace header, and new audit actions; existing requests behave as before. See Team, Workspaces and environments and People, invites and workspaces.

2026-10-30: setup-link errors and route names in the audit trail

When our sign-in service fails, POST /v1/auth/account-setup/complete and email-setup now answer 503 auth_unavailable (retry after Retry-After) or 502 auth_misconfigured, instead of a 401 or a bare 5xx. The link stays usable, and email-setupsends nothing. Audit route names now use the same parameter names as the API reference: the catalogue's trigger routes and an event's request.path read /v1/team/synthetic-accounts/:display_id, not :displayId. See Synthetic accounts.

2026-10-29: one naming style, snake_case

Every field, query parameter, webhook payload and error now uses snake case, like the routes added since October 22 (reason_code, respond_by, page_size, card_brand, status_code). Nothing breaks: requests still accept the old camel case names (snake case wins if you send both), and responses and webhook payloads of the original routes still carry each old camel case field next to its new name, with Deprecation: true. The OpenAPI document, these docs and the example suite use snake case only; move your client over when it suits you. The camel case names will be removed later, announced here first. See Names.

2026-10-29: synthetic accounts can set their password by email

A synthetic account can now get its password from a single-use setup link mailed to its own plus-address, so the owner never sees it: credential_mode: "email" on create, and POST /v1/team/synthetic-accounts/:display_id/email-setup to switch, resend or change the address. Accounts gain credential_mode and setup; rotate-mfa answers 409 credential_mode_email on such an account. Existing accounts stay generated and behave as before. See Synthetic accounts.

2026-10-29: the webhook test sends to one webhook

POST /v1/webhooks/:id/test now sends its dispute.created test event to that webhook only (your other webhooks receive nothing), and an unknown id answers 404 webhook_not_found instead of 200 with {"ok": false}. The webhook.test audit row says webhooks_scheduled: 1. The same release makes GET /v1/transactions/export.csv take the list filters and refuse more than 100,000 rows with 413 export_too_large before sending anything.

2026-10-28: flow profiles, scheduled jobs and settings

Flow profiles let the workspace owner switch operations into other flows for testing (slow reports, failing actions, short deadlines, duplicated webhooks), with waits you fast-forward instead of sleeping through. Everything is opt-in; without a profile nothing changes. See Flows and, for workspace settings, preferences and the dashboard's currency and tz parameters, Settings.

2026-10-28: report retention and a 100,000-row cap

Reports are kept for 90 days. After that a finished report turns expired, its file is deleted, and its download link answers 410 report_expired; reports still queued or running are never expired. A report holds at most 100,000 rows: a bigger one ends failed with error_code: report_too_large (narrow the date range). Reports under the cap produce the same files as before.

2026-10-27: generate at volume

Generate jobs run in the background in chunks of 500 transactions, up to 20,000 per job from the dev panel (API keys: 500), with progress, an estimated finish time and cancel. Cleaning up a job of more than 2,000 transactions, and resetting a workspace, now run in the background too. See Dev panel.

2026-10-28: session expiry and revocation for synthetic accounts

Archiving a synthetic account or rotating its password or credentials now ends its sessions at once: a token it already holds answers 401 session_revoked on its next request instead of working until it expires. The owner can also revoke them on purpose with POST /v1/team/synthetic-accounts/:id/revoke-sessions.

To test how your client handles an expired session without waiting an hour, call POST /v1/team/synthetic-accounts/:id/expire-sessions (flow.write: the owner, or a key the owner minted with the E2E preset): the account’s next request answers 401 session_expired. A new flow variant, session.max_age, makes synthetic-account sessions expire after 60 to 3,600 seconds. The account cannot set it for itself; it comes from the profile the owner assigned to it or the workspace default. Owner sessions are never affected.

2026-10-28: webhook sequence and delivery variants

Every webhook delivery body now carries sequence, a number that grows with each delivery (gaps expected). Order events by it. The field is additive: the rest of the body is unchanged.

Webhooks gain accepts_simulation and a run-scope filter, and dev sinks a filter. A new flow variant, webhook.delivery, delays, duplicates, mis-signs, fails, drops or reorders deliveries to simulation subscribers only, so you can test an idempotent, signature-checking receiver. Webhooks pointing at your dev sinks are simulation subscribers; your other webhooks receive exactly what they did before. See Webhooks.

2026-10-28: multi-stage lifecycles

A new flow variant, lifecycle.escalation, lets you test the steps that come after a dispute seems finished: an expired or answered request for information that turns into a chargeback, a scheme arbitration ruling, an issuer that withdraws a chargeback, and a win reversed in pre-arbitration (the dispute needs evidence again, with a new deadline, once). Each step is opt-in and off by default, so disputes generated without it behave as before. Timelines show the new escalated and reversed events, and payment timelines a chargeback_reopened event.

2026-10-28: payment events for new disputes

A dispute created from today on records its later steps on its payment timeline (GET /v1/payments/:id and the payment page in the portal): accepted (dispute_accepted), evidence submitted (dispute_evidence_submitted), won, lost, expired, arbitration filed, and an answered or expired request for information. Before, only the events present when the dispute was generated were there. Timelines of disputes created before today stay exactly as they were.

The same release adds lifecycle flow variants for testing: response windows and issuer reviews in minutes, rulings that are always won, always lost or seeded, and opt-in lifecycle webhooks to your dev webhook sinks. A dispute generated without a flow profile behaves as before.