API reference

Flows and scheduled jobs

A flow profile switches operations into other flows, so your systems can be tested against them: a report that takes three hours, a ruling that is always lost, a challenge that fails once, a webhook that arrives twice. Waits are database rows you can fast-forward, so tests never sleep. Everything is opt-in: a workspace that never creates a profile, and a request that names none, behaves exactly as before.

Profiles and versions

  • A flow profile (fp_…) is a named set of variant keys from the catalogue, for example {"report.delivery": {"mode": "async", "delay_s": 10800}}. Create it with POST /v1/flow-profiles ({name, description?, variants}; names are [a-z0-9-], 1 to 64 characters).
  • A profile is complete on its own.Every key it does not set takes the platform default, which is today's behaviour. Profiles are never merged or layered, so what a test names is exactly what it gets.
  • Versions are immutable. PATCH /v1/flow-profiles/:id writes a new version with the complete variants (not a merge) and moves current_version. Old versions stay readable (GET /v1/flow-profiles/:id/versions) and usable.
  • Pinning. A reference is fp_x (the current version, looked up on each request) or fp_x@3 (version 3, forever). Every profile response carries ref, the pinned reference to its current version: send that from tests, so an edit someone makes in the portal never changes a running test.
  • fp_platformis a reserved, read-only id that means today's behaviour, whatever the workspace default is.
  • Create is idempotent by name and body. The same name with the same description and variants returns the existing profile with 200; a different body is 409 flow_profile_name_taken. The example fixture names profiles <name>-<hash8> from a hash of the variants, so two branches with different variants never collide.
  • Archive, never delete. DELETE /v1/flow-profiles/:id archives it and frees the name at once. Requests that still name it keep working for 24 hours with X-DM-Flow-Warning: archived, then get 400 invalid_flow_profile. Profiles unused for 30 days that are neither the workspace default nor assigned to a synthetic account are archived automatically.
  • Unknown keys or values outside a key's schema are 400 invalid_flow_variant with details: [{field, constraints}], at save time, never at request time.
Create a profile, then use its pinned ref
curl -s -X POST https://api.dispute.markets/v1/flow-profiles \
  -H "Authorization: Bearer $DM_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: profile-report-3h-01" \
  -d '{"name":"report-3h","variants":{"report.delivery":{"mode":"async","delay_s":10800}}}'

{"id":"fp_01J9Z3…","object":"flow_profile","name":"report-3h","current_version":1,
 "ref":"fp_01J9Z3…@1","variants":{"report.delivery":{"mode":"async","delay_s":10800}},
 "body_hash":"sha256:…","used_by":{"synthetic_accounts":0,"workspace_default":false}, …}

Choosing a flow: X-DM-Flow and the dm_flow cookie

A request runs under a profile when it sends X-DM-Flow: fp_…@N (or fp_…, or fp_platform). In the browser, a test sets the dm_flow cookie on the portal host (or the owner presses Use in this browser in Settings › Flows) and the portal forwards it as X-DM-Flow, the same way it forwards dm_correlation_id.

Header or cookieWhereMeaning
X-DM-FlowRequestThe profile for this request: fp_platform, fp_… or fp_…@N (at most 72 characters). When honoured, an unknown, foreign or long-archived profile is 400 invalid_flow_profile.
dm_flowCookie on the portal hostSet by your test or by “Use in this browser” (pinned to a version). The portal forwards it as X-DM-Flow for the owner only.
X-DM-Flow-AppliedEvery responseThe profile this request, and any resource it created, used: fp_…@N, or fp_platform. The portal copies it back.
X-DM-Flow-RejectedPortal responsesWhy the portal did not forward a dm_flow value: empty, too_long, invalid_syntax, version_out_of_range (also logged as a console warning), or not_permitted for a non-owner (silent).
X-DM-Flow-WarningAPI responsesComma-separated: archived (the profile was archived less than 24 hours ago), ignored_not_permitted, latency_budget (api.latency skipped), rate_limit_needs_correlation (api.rate_limit skipped without X-Correlation-Id).

The workspace default

PUT /v1/flow-profiles/default with {"profile_id": "fp_…@N"} (or null / fp_platformto clear it) sets what requests without a header, and synthetic accounts without a profile of their own, resolve to. It applies to resources created from then on; existing ones keep their stamp. Only the owner's signed-in session can set it (Settings › Flows, or the dev panel's Flow defaults card): no API key can hold flow.default.write. It exists for people clicking in the portal. Tests should not depend on it: the example fixture sends fp_platform on every call so a default someone sets never reaches CI.

Resolution, scopes and snapshots

Every catalogue key has a scope that says when it is read:

ScopeReadKeys
requestFrom the current request, every time.report.download_url, report.limits, dispute.action_failure, dispute.action_ack, api.latency, api.rate_limit, evidence.download_url, evidence.limits, webhook.delivery, session.max_age
stampedOnce, when the resource is created, then written on it as a snapshot and read only from there.report.delivery, report.outcome, lifecycle.ruling, lifecycle.windows, lifecycle.review, lifecycle.webhooks, lifecycle.escalation, evidence.processing, generate.profile, generate.trickle

The profile a request resolves to, first match wins:

  1. X-DM-Flow, when the caller may opt in (the owner, or an owner-minted key with flow.read). Profile source header.
  2. For a request authenticated as a synthetic account: the profile the owner assigned to that account. Source account.
  3. The workspace default. Source tenant_default.
  4. fp_platform, today's behaviour. Source platform.

webhook.delivery is read from the request that triggered the event when there is one, else from the stamp of the resource that changed (a lifecycle transition, a finished report).

Snapshot, not reference

A resource that acts later (a report, a dispute from a generate job, an evidence upload, a held webhook delivery, a scheduled job) stores flow: {profile_id, version, source, variants} with the concrete values of its stamped keys, even for fp_platform. Later work reads the snapshot, never the live profile, so editing or archiving a profile, or changing the workspace default, never changes a resource that already exists. A resource created before flows existed behaves as fp_platform.

Worked example: a request profile acting on a stamped resource

Profile A makes reports slow; profile B makes download links short-lived. A report created under A and downloaded under B gets both, each from the place its key's scope says:

1. Create under A: report.delivery is stamped on the report
POST /v1/reports            X-DM-Flow: fp_A@1
  A = {"report.delivery": {"mode": "async", "delay_s": 3600}}

201 X-DM-Flow-Applied: fp_A@1
{"id":"rpt_…","status":"queued","ready_at":"<now + 1 h>",
 "flow":{"profile_id":"fp_A","version":1,"source":"header",
         "variants":{"report.delivery":{"mode":"async","delay_s":3600},"report.outcome":{"mode":"ok"}}}, …}
2. Later, under B: the stamp still decides delivery, B decides the link
POST /v1/scheduled-jobs/fast-forward   X-DM-Flow: fp_B@1
  {"target_id": "rpt_…", "wait": true}      → the build runs now; the report is done
GET  /v1/reports/rpt_…/download-url    X-DM-Flow: fp_B@1
  B = {"report.download_url": {"ttl_s": 60}}
200 {"url":"https://…","expires_at":"<now + 60 s>"}
  • B does not set report.delivery, but that does not make the report synchronous: it is a stamped key, read from the report's snapshot.
  • A does not set report.download_url, but the link lives 60 seconds, not the default 300: it is a request key, read from B.
  • The audit trail says which is which. The report.build row carries flow: {profile_id: "fp_A@1", profile_source: "header", variants: {"report.delivery": {"value": {…}, "source": "stamped"}}}; the report.create_download_url row carries flow: {profile_id: "fp_B@1", …, variants: {"report.download_url": {"value": {"ttl_s": 60}, "source": "request"}}}.

Variant catalogue

Every key, rendered from the same data as GET /v1/flows/catalogue(which also returns each key's full JSON Schema). ? marks an optional field; unsetmeans the key does nothing unless a profile sets it. “Emits” lists every error code, audit action and webhook event the variant can produce, so a test can assert on exactly the failure it asked for.

Key, operation, scopeBounds and defaultWhat it testsEmits

report.delivery

POST /v1/reports

scope stamped

mode: sync | async

delay_s?: 0–86400

build_s?: 0–86400

default: {"mode":"sync"}

async creates the report queued with ready_at = now + delay_s (201 at once); at ready_at it turns running for build_s seconds with a time-derived progress_pct (steps of 10), then builds and ends done or failed, and a report.ready webhook goes to simulation subscribers. delay_s + build_s is at most 86,400. Fast-forward the report (POST /v1/scheduled-jobs/fast-forward {target_id}) to skip the wait. sync (the default) builds during the request, as always.

errors: too_many_scheduled_jobs, canceled, job_lease_expired

audit: report.create, report.build, report.retry

webhooks: report.ready

report.outcome

POST /v1/reports

scope stamped

mode: ok | fail | fail_then_ok | empty | truncated

error_code?: report_timeout | report_too_large | internal

n?: 1–3

row_limit?: 1–10000

default: {"mode":"ok"}

fail ends the report failed with error_code (default internal); fail_then_ok fails the first n attempts (default 1) and POST /v1/reports/{id}/retry runs the next one; empty writes a header-only CSV; truncated keeps at most row_limit rows (default 100) and sets truncated: true. A simulated failure keeps the chosen error_code on the report, sets simulated_failure: true, says so in error_message, and is audited as outcome=failed with error_code simulated_<error_code>.

errors: report_timeout, report_too_large, internal

audit: report.create, report.build, report.retry

webhooks: report.ready

report.download_url

GET /v1/reports/:id/download-url

scope request

ttl_s: 1–604800

or

mode: "expired"

default: {"ttl_s":300}

The presigned report link lives ttl_s seconds (1 s to 7 days; default 300) and the response says when in expires_at. {mode: "expired"} returns a link signed in the past, so storage answers 403 at once: test re-fetching an expired link without sleeping.

errors: none

audit: report.create_download_url

webhooks: none

report.limits

POST /v1/reports

scope request

max_live: 1–100

default: unset

Limits how many reports can exist at once, per run: the part of X-Correlation-Id before the first : (default without one, so portal use is in practice workspace-wide). A create at the limit is 409 report_limit_reached; its details.oldest lists the run's oldest live reports: delete one (DELETE /v1/reports/{id}), then retry with a new Idempotency-Key. Unset (the default): no limit. GET /v1/flows/limits shows the run's usage as reports_live.

errors: report_limit_reached

audit: report.create, report.delete

webhooks: none

lifecycle.ruling

POST /v1/dev/generate

scope stamped

mode: random | always_won | always_lost | probability

p?: 0–1

seed?: 0–2147483647

default: {"mode":"random"}

How the issuer rules on a dispute under review. always_won / always_lost decide every ruling; random keeps today's 60 % win rate unless p (0-1) is set; probability wins with probability p (default 0.6). With a seed the draw is a hash of (this seed, the generate job's seed, the dispute's ordinal in the job), so two generate runs with the same seeds rule every dispute the same way. An issuer_decision of won or lost on advance-lifecycle still wins. Stamped on each dispute when generate creates it; the hourly cron and scheduled rulings read the stamp, never the workspace default.

errors: none

audit: dispute.rule

webhooks: dispute.status_changed

lifecycle.windows

POST /v1/dev/generate

scope stamped

evidence_s?: 30–5184000

rfi_s?: 30–5184000

default: unset

Response windows of generated disputes, in seconds (30 s to 60 days): evidence_s for disputes awaiting evidence, rfi_s for open requests for information. Their respond_by is now + the window, and the dispute expires through a scheduled job at that instant instead of the hourly scan (lifecycle_mode: scheduled). Fast-forward the dispute (POST /v1/scheduled-jobs/fast-forward {target_id}, or to_remaining) to move respond_by and the expiry together. Unset (the default): the scheme table (Visa 20 days, …) and the hourly scan. Not with an explicit lifecycle: manual on generate (400 lifecycle_conflict).

errors: lifecycle_conflict

audit: dispute.expire, dispute.expire_rfi, dispute.lifecycle_stop

webhooks: dispute.status_changed

lifecycle.review

POST /v1/dev/generate

scope stamped

duration_s: 30–5184000

or

mode: "never"

default: unset

How long the issuer reviews a challenged dispute: duration_s (30 s to 60 days) after it enters under_review (a generated dispute already under review: after it was created), then it rules under lifecycle.ruling through a scheduled job (lifecycle_mode: scheduled). {mode: "never"} keeps it under_review for good: "waiting on the issuer". Unset (the default): today's 55 days from opening and the hourly scan.

errors: lifecycle_conflict

audit: dispute.rule, dispute.lifecycle_stop

webhooks: dispute.status_changed

lifecycle.webhooks

POST /v1/dev/generate

scope stamped

enabled: boolean

default: {"enabled":false}

enabled: true sends dispute.status_changed for every lifecycle transition (expiry, ruling) of the stamped disputes, and dispute.created for each dispute generate creates (at most 200 per generate job; the rest are counted in webhooks_skipped on generate_job.run). Only simulation subscribers get them: webhooks with accepts_simulation (a webhook created on a dev webhook sink URL has it unless you set it false), within their run filter. Your other webhooks keep today's deliveries. Default false: lifecycle transitions send no webhook.

errors: none

audit: generate_job.run

webhooks: dispute.status_changed, dispute.created

lifecycle.escalation

POST /v1/dev/generate

scope stamped

rfi_expired_to_chargeback?: object

rfi_responded?: object

arbitration_ruling?: object

issuer_cancel?: object

won_reversal?: object

default: unset

Opt-in transitions out of statuses that are dead ends by default, so multi-stage flows can be tested. Each sub-key is optional; after_s (30 s to 60 days) counts from when the dispute entered the status, and p (0-1) is a per-dispute draw that is the same on every run. rfi_expired_to_chargeback: an expired RFI becomes a chargeback (evidence_required, a fresh respond_by from lifecycle.windows.evidence_s or the scheme window, timeline event escalated, payment event dispute_opened). rfi_responded: an answered RFI is closed (resolved) or escalated to a chargeback (outcome: close, escalate, or random with p the escalation probability, default 0.5). arbitration_ruling: the scheme rules on arbitration_under_review (arbitration_won / arbitration_lost; mode, p and seed as in lifecycle.ruling; the filing fee stays charged either way). issuer_cancel: the issuer withdraws an open chargeback (evidence_required or under_review) and it becomes canceled. won_reversal: a won dispute is reopened in pre-arbitration (evidence_required with a fresh respond_by, timeline event reversed, payment event chargeback_reopened); it happens once, so a second win is final. Audited dispute.escalate (dispute.rule for the arbitration ruling) with metadata.transition; with lifecycle.webhooks enabled each fires dispute.status_changed to simulation subscribers. Scheduled disputes run them as dispute.escalate jobs (fast-forward them like deadlines); other disputes move on the hourly pass or advance-lifecycle. Stamped on each dispute when generate creates it; never read from the workspace default afterwards. Unset (the default): these statuses stay where they are.

errors: none

audit: dispute.escalate, dispute.rule

webhooks: dispute.status_changed

dispute.action_failure

POST /v1/disputes/:id/{accept,challenge,rfi-response,arbitrate}

scope request

actions: list of 4 values

mode: always | first_n_per_target | timeout_after_commit

n?: 1–3

error?: scheme_unavailable | service_unavailable

default: unset

Makes the listed dispute actions (accept, challenge, respond_rfi, arbitrate) fail on purpose, after the request was validated and before anything is written. error picks the failure: scheme_unavailable (409) or service_unavailable (503 with Retry-After: 2, the default). always fails every attempt; first_n_per_target fails the first n attempts (1-3, default 1) on each dispute, counted per profile version and dispute, so parallel tests on different disputes never use up each other's failures, and the next attempt succeeds. timeout_after_commit performs the action, then answers 504 simulated_timeout: retry with the same Idempotency-Key and the stored success is replayed (Idempotent-Replayed: true), so you can test "did it land?" handling. A simulated failure is audited as outcome=failed with error_code simulated_<error>; a simulated timeout writes the normal success row with metadata.simulated_timeout: true. Unset (the default): no injected failures.

errors: scheme_unavailable, service_unavailable, simulated_timeout

audit: dispute.accept, dispute.challenge, dispute.respond_rfi, dispute.arbitrate

webhooks: none

dispute.action_ack

POST /v1/disputes/:id/{challenge,arbitrate}

scope request

actions: list of 2 values

delay_s: 1–86400

default: unset

The listed actions (challenge, arbitrate) are acknowledged instead of applied: 202 with the dispute, its status unchanged and pending_action {action, requested_at, completes_at, job_id}. After delay_s seconds (1 s to 24 h) a scheduled job applies the transition (dispute.complete_action, on behalf of the caller) and dispute.status_changed goes to your webhooks as for a direct action. While an action is pending every other action on the dispute is 409 action_pending. Evidence counted by a pending challenge is the evidence attached when it was requested. Fast-forward the dispute (POST /v1/scheduled-jobs/fast-forward {target_id}) to complete it now; cancel its job to drop the pending action (dispute.cancel_action, status unchanged). At most 200 pending actions per workspace (100 per correlation prefix): 409 too_many_scheduled_jobs. Unset (the default): actions apply at once.

errors: action_pending, too_many_scheduled_jobs

audit: dispute.challenge, dispute.arbitrate, dispute.complete_action, dispute.cancel_action

webhooks: dispute.status_changed

api.latency

GET (the listed routes)

scope request

delay_ms: 1–10000

routes: list of 23 values

default: unset

Slows the listed GET routes: the API waits delay_ms (1-10,000 ms) before it runs the request, holding no database connection, so a client timeout or a spinner can be tested for real. The wait stops when the client disconnects. At most 10 delayed requests per workspace are in flight on one server; past that the request runs at once and the response carries X-DM-Flow-Warning: latency_budget. Only GET data reads can be listed (not the flow profile, catalogue or scheduled-job routes); any other route is 400 invalid_flow_variant at profile save. Unset (the default): no delay.

errors: none

audit: none

webhooks: none

api.rate_limit

GET (the listed routes)

scope request

routes: list of 23 values

first_n_per_correlation: 1–5

retry_after_s?: 1–60

default: unset

Throttles the listed GET routes: the first first_n_per_correlation calls (1-5) of each route per X-Correlation-Id answer 429 rate_limited with Retry-After: retry_after_s (1-60 s, default 1) and the standard error body (details.simulated: true); later calls with that correlation id go through. Counted per profile version, route and correlation id, so every test (with its own correlation id) sees its own 429s first. Without X-Correlation-Id the variant is skipped and the response carries X-DM-Flow-Warning: rate_limit_needs_correlation. Audited once per correlation id and route as api.rate_limit_simulated (outcome=failed, error_code=simulated_rate_limited). Only GET data reads can be listed; any other route is 400 invalid_flow_variant at profile save. Unset (the default): no throttling.

errors: rate_limited

audit: api.rate_limit_simulated

webhooks: none

evidence.download_url

GET /v1/disputes/:id/evidence/:evidenceId/download-url

scope request

ttl_s: 1–604800

or

mode: "expired"

default: {"ttl_s":300}

Like report.download_url, for evidence files: ttl_s (1 s to 7 days; default 300), or {mode: "expired"} for a link storage refuses at once.

errors: none

audit: evidence.create_download_url

webhooks: none

evidence.processing

POST /v1/disputes/:id/evidence

scope stamped

delay_s: 1–86400

outcome: available | rejected

reason?: virus_detected | unsupported_type | unreadable

default: unset

Finalizing an upload creates the evidence processing (201, processing_until = now + delay_s, 1 s to 24 h) instead of available. At processing_until a scheduled job sets it available, or rejected with rejection_reason (reason: virus_detected, unsupported_type or unreadable; required with outcome: rejected) and deletes the stored file. Stamped on the evidence (evidence.flow). While it is processing or rejected its download URL is 409 evidence_not_available, and a challenge counts only available evidence: with only processing evidence it is 409 evidence_processing. The result is audited evidence.process (system, on behalf of the uploader), and when the dispute was created with lifecycle.webhooks enabled an evidence.processed webhook goes to simulation subscribers. Fast-forward the evidence (POST /v1/scheduled-jobs/fast-forward {target_id: "evd_…"}) to skip the wait. At most 100 files processing per workspace (50 per correlation prefix): 409 too_many_scheduled_jobs. Unset (the default): evidence is available at once.

errors: evidence_processing, evidence_not_available, too_many_scheduled_jobs

audit: evidence.create, evidence.process

webhooks: evidence.processed

evidence.limits

POST /v1/disputes/:id/evidence

scope request

max_bytes?: 1–26214400

content_types?: list

max_files_per_dispute?: 1–50

default: unset

Stricter upload validation when an upload is finalized (the stored file's size and type, from storage): larger than max_bytes (1 B to 25 MB) is 413 evidence_too_large; a type not in content_types (up to 20 lower-case media types such as application/pdf, or image/*) is 415 evidence_type_not_allowed; a dispute that already holds max_files_per_dispute (1-50) processing or available files is 409 evidence_limit_reached. Each rejection deletes the stored file and is audited evidence.create with outcome=failed and the code. Unset (the default): only the platform cap applies (25 MB, 413 evidence_too_large, for every upload).

errors: evidence_too_large, evidence_type_not_allowed, evidence_limit_reached

audit: evidence.create

webhooks: none

generate.profile

POST /v1/dev/generate

scope stamped

mix: realistic | actionable | terminal

deadline_anchor: opened | now

reason_codes?: list of 10 values

currencies?: list of 6 values

amount_edges?: boolean

default: {"mix":"realistic","deadline_anchor":"opened"}

What generated disputes look like. mix: realistic (today's spread of open and finished disputes), actionable (every dispute waits on you: evidence_required or rfi_open, the portal's "unanswered" tab) or terminal (only finished disputes: won, lost, accepted, expired, canceled, resolved, rfi_expired, arbitration_won, arbitration_lost). deadline_anchor: opened (today: respond_by is the scheme window after a back-dated opened_at, so most open disputes are already past it and the next lifecycle pass expires them) or now (an open dispute's respond_by lies 20-100 % of its window ahead of the run, and it opened one window before that). reason_codes: open disputes with these codes only. currencies: mint transactions in these currencies instead of the request's currency. amount_edges: about 15 % of amounts are 0.01 (one minor unit), 15 % the currency's maximum (99,999,999 minor units) and, without currencies, 15 % in a zero-decimal currency (JPY). With a seed the output is reproducible. Stamped on the generate job (and its disputes) when it is created. Unset (the default): realistic anchored to opened, today's output for a given seed.

errors: none

audit: generate_job.create

webhooks: none

generate.trickle

POST /v1/dev/generate

scope stamped

per_minute: 1–60

duration_s: 60–86400

default: unset

Disputes that arrive over time instead of all at once. The generate job creates per_minute (1-60) disputes in its first batch at once, then one batch a minute as a generate.trickle_batch scheduled job, until it has created count disputes or duration_s (60 s to 24 h; duration_s / 60 batches) has passed. Every trickle transaction carries a dispute (dispute_rate is ignored), so count is the number of disputes. Each batch stamps its disputes with the job's snapshot and the next generate_ordinal, and with lifecycle.webhooks enabled sends their dispute.created webhooks as it commits. GET /v1/dev/generate/{id} shows created_so_far and trickle; fast-forward the job (POST /v1/scheduled-jobs/fast-forward {generate_job_id}) to run the next batch at once; cancel or clean it up to stop later batches. The job holds the in-flight budget only while a batch is pending. Unset (the default): one batch, as fast as the job runs.

errors: none

audit: generate_job.create, generate_job.run

webhooks: dispute.created

webhook.delivery

Webhook deliveries to simulation subscribers

scope request

delay_s?: 0–86400

duplicates?: 1–3

duplicate_gap_s?: 0–300

signature?: valid | invalid | missing

fail_first_n?: 0–5

drop?: boolean

reorder_window_s?: 0–60

default: unset

How webhook deliveries to simulation subscribers behave, so you can test receiver idempotency, signature checks, late and reordered events and reconciliation by polling. Applies only to simulation subscribers (webhooks with accepts_simulation, within their run filter); every other webhook gets the normal delivery. Read from the request that triggered the event (X-DM-Flow, the synthetic account's profile, then the workspace default); events without a request (lifecycle transitions, dispute.created after generate, report.ready, evidence.processed) use the value stamped on the dispute, generate job, report or evidence file when it was created. delay_s (0 to 86,400) holds the delivery (status: held) and a webhook_delivery.release scheduled job releases it; fast-forward its delivery id (POST /v1/scheduled-jobs/fast-forward {target_id: "whd_…"}) to release it at once. duplicates (1-3) sends the same delivery id and body that many times, duplicate_gap_s (0-300) apart. signature: invalid signs with a random key and missing leaves X-Dispute-Signature out. fail_first_n (0-5) records that many attempts as failed (error_code: simulated_failure) without sending, then retries on the normal schedule (1, 5, 15, 40 min; fast-forward does not move retries). drop: true stores the delivery failed (simulated_drop) and never sends it. reorder_window_s (0-60) holds a dispute's deliveries to one subscriber for the window and releases them newest first; order by sequence in the body. A workspace holds at most 1,000 pending simulated attempts (held deliveries, duplicates and simulated failures); past that a delivery is stored failed with delivery_backlog_full. GET /v1/webhooks lists each webhook's recent deliveries with the variant applied. Unset (the default): deliveries are made at once, once, validly signed.

errors: simulated_failure, simulated_drop, delivery_backlog_full

audit: webhook_delivery.send

webhooks: none

session.max_age

Every request authenticated as a synthetic account

scope request

seconds: 60–3600

default: unset

Shortens the sessions of synthetic accounts so you can test re-authentication without waiting an hour: once an access token is older than seconds (60 to 3,600), every request with it answers 401 session_expired, and the client has to sign in again. Applies to synthetic accounts only, never to the owner or to API keys. The account cannot choose it: its own X-DM-Flow header and dm_flow cookie stay ignored, so the value comes from the profile the owner assigned to the account, else from the workspace default. Audited once per token as auth.session_expire (outcome=failed, error_code=session_expired). For an instant expiry with no wait at all, call POST /v1/team/synthetic-accounts/:id/expire-sessions. Unset (the default): sessions last as long as the token (60 minutes).

errors: session_expired

audit: auth.session_expire

webhooks: none

Recipe profiles

Copy one into POST /v1/flow-profiles (or start from it in Settings › Flows › Start from recipe).

slow-report: A report that is ready only an hour later and then builds for a minute: your queued, running and ready states, polling and the report.ready webhook.

{
  "name": "slow-report",
  "variants": {
    "report.delivery": {
      "mode": "async",
      "delay_s": 3600,
      "build_s": 60
    }
  }
}

flaky-challenge: The first challenge of each dispute fails with 409 scheme_unavailable and the retry succeeds: your retry path.

{
  "name": "flaky-challenge",
  "variants": {
    "dispute.action_failure": {
      "actions": [
        "challenge"
      ],
      "mode": "first_n_per_target",
      "n": 1,
      "error": "scheme_unavailable"
    }
  }
}

lose-fast: Generated disputes expire one minute after they open, and a challenged one is lost one minute later: deadline and loss handling without waiting weeks.

{
  "name": "lose-fast",
  "variants": {
    "lifecycle.windows": {
      "evidence_s": 60,
      "rfi_s": 60
    },
    "lifecycle.review": {
      "duration_s": 60
    },
    "lifecycle.ruling": {
      "mode": "always_lost"
    }
  }
}

noisy-webhooks: Every lifecycle transition of generated disputes (expiry, ruling) sends dispute.status_changed, and generate sends dispute.created, to your simulation subscribers.

{
  "name": "noisy-webhooks",
  "variants": {
    "lifecycle.webhooks": {
      "enabled": true
    }
  }
}

slow-scan: Uploaded evidence stays processing for ten minutes before it becomes available: your "file not ready yet" state and the 409 evidence_processing on challenge.

{
  "name": "slow-scan",
  "variants": {
    "evidence.processing": {
      "delay_s": 600,
      "outcome": "available"
    }
  }
}

Per-synthetic-account profiles

A synthetic account cannot choose a flow (its header and cookie are ignored), but the owner can assign it one. Requests authenticated as that account then resolve through the account's profile → the workspace default → fp_platform, for request keys on every request and for stamped keys when it creates something. Owner sessions and API keys are unaffected: a key never inherits an account's profile.

flow_profile valueRequests as the account use
nullThe workspace default (else today’s behaviour). The default for new accounts.
fp_platformAlways today’s behaviour, whatever the workspace default is.
fp_xThat profile’s current version, looked up on each request.
fp_x@NVersion N, pinned.
  • Set it with flow_profile on POST /v1/team/synthetic-accounts and change it with PATCH /v1/team/synthetic-accounts/:id(omit it to keep the assignment). It applies from the account's next request, on every server. An unknown or archived profile is 400 invalid_flow_profile.
  • Owner only. Sending flow_profile (any value, null included) needs flow.write on top of team.write; without it the whole request is 403 missing_permission and nothing is written. API keys cannot manage synthetic accounts at all (401 api_key_not_allowed).
  • Reads return flow_profile: {ref, profile_id, name, version, archived_at} (or null), only to callers holding flow.read; it is omitted for everyone else. The synthetic_account.create and synthetic_account.update audit rows record it in changes.flow_profile.
  • Audit rows of requests the account made, and the snapshots of what it created, carry profile_source: "account" (header, account, tenant_default or, in snapshots, platform).
  • Archiving a profile in use is allowed. Profile reads and the archive response carry used_by: {synthetic_accounts, workspace_default}. For 24 hours the accounts keep the archived profile (responses carry X-DM-Flow-Warning: archived); after that their assignment is ignored and they resolve through the workspace default, and a background job clears each assignment with a synthetic_account.flow_profile_released audit row (changes: {flow_profile: {from: 'fp_…', to: null}}, reason: profile_archived).

The testing pattern: one account per worker

Give each parallel browser worker its own synthetic account and assign each account its profile once, in worker setup, so workers run different variants at the same time, request keys such as session.max_age and dispute.action_failure included. Never change an assignment inside a test: concurrent CI runs that share an account pool share its assignments. The example does this with a worker option (see the E2E guide):

examples/e2e-playwright/tests/ui/23-account-profile-failing.spec.ts
import { expect, hasOwner, uiTest as test } from '../../src/dm';
import { syntheticAccounts } from '../../src/env';
import { disputeReadyToChallenge, SCHEME_DOWN, submitChallenge } from './account-profiles';

test.skip(syntheticAccounts().length < 2 || !hasOwner(), 'needs DM_OWNER and two DM_SYNTHETIC_ACCOUNTS');

// (9a) Per-account flow profiles. The owner assigns this worker's account
// (the first in DM_SYNTHETIC_ACCOUNTS) a profile that fails every
// challenge, in worker setup, and the worker restores the old assignment
// when it ends. A synthetic account cannot choose a flow itself (its
// dm_flow cookie is ignored); it gets what the owner assigned. The other
// half (23-account-profile-platform) runs at the same time as another
// account and sees the normal path. Tagged @account-profiles: concurrent
// CI jobs share the account pool, so only one of them runs the pair
// (`--grep-invert @account-profiles` in the others).
test.use({ accountSlot: 0, accountFlow: SCHEME_DOWN });

test('@account-profiles an account assigned a failing profile sees the scheme outage', async ({ page, dm }) => {
  const { ref } = await dm.flowProfile(SCHEME_DOWN.name, SCHEME_DOWN.variants);
  const disputeId = await disputeReadyToChallenge(dm);
  const mark = await dm.mark();

  await submitChallenge(page, disputeId);
  await expect(page.getByText('The card scheme is unavailable; the action was not applied. Retry later.')).toBeVisible();

  const [row] = await dm.waitFor({ after_seq: mark, correlation_id: dm.correlationId, action: 'dispute.challenge' });
  expect(row).toMatchObject({
    outcome: 'failed',
    error_code: 'simulated_scheme_unavailable',
    actor: { type: 'synthetic_account' },
    request: { source: 'portal' },
    target: { id: disputeId },
    flow: { profile_id: ref, profile_source: 'account' },
  });
  expect((await dm.api.get<{ status: string }>(`/v1/disputes/${disputeId}`)).status).toBe('evidence_required');
});

When a restricted-role test needs a variant that differs per test on the same account, split it: create the resource with an owner-minted key under X-DM-Flow so the variant is stamped on it, then drive it from the restricted browser; use expire-sessions for expiry; and test request-scope failure UX in an owner context.

In the portal

  • Team › Synthetic accounts: the create and edit dialogs have a Flow profileselect (Workspace default, Platform behaviour, each profile as “always the latest version” and each version pinned). The list shows a badge on every account with its own profile, and a warning badge while an archived profile is in its 24-hour grace.
  • Settings › Flows: each profile shows “Used by N synthetic accounts” (linking to Team, filtered) and a “Workspace default” tag; archiving one in use warns with the count and the 24-hour fallback.
  • Dev panel › Flow defaults: switch the workspace default, or edit its values (saved as a new version, then the default moves to it). It is the same setting as Settings › Flows, through the same API, and says how many accounts use their own profile and are not affected.

All of it is shown to the owner only.

Scheduled jobs and time travel

Every wait a variant introduces (a report's delay, a response window, a review, an acknowledgement, evidence processing, a held webhook) and every chunk of a generate job is a scheduled job (sj_…): a database row that survives deploys and restarts and runs at run_at. A test either waits for it for real or moves it forward.

States and reading them

scheduled → running → succeeded, failed or canceled. A job whose server was interrupted goes back to scheduled by itself and is retried, up to the job's max_attempts; one that runs out of attempts fails with job_lease_expired and puts its target in its failed state. Finished jobs are kept 7 days (failed: 30).

RouteWhat
GET /v1/scheduled-jobsNewest first, cursor-paginated ({next_cursor, has_more}). Filters: state, kind, target_id (rpt_…, dsp_…, evd_…, whd_…, gj_…), generate_job_id, correlation_id (exact, or a prefix of at least 4 characters with a trailing *).
GET /v1/scheduled-jobs/:idOne job: kind, state, target, run_at, original_run_at, fast_forwarded_by, started_at, lateness_ms, attempts, last_error_code, flow, correlation_id. With ?wait_s= (at most 25) it returns once the job is terminal or the time is up: wait on a job without audit.read. At most 8 such waits per caller at a time per server (429 too_many_waits).

Fast-forward

POST /v1/scheduled-jobs/fast-forward takes exactly one selector, optional narrowing and one amount:

FieldMeaning
job_idsSelector: up to 200 job ids.
target_idSelector: the jobs of one resource (rpt_…, dsp_…, evd_…, whd_…, gj_…).
generate_job_idSelector: every job of one generate job.
correlation_idSelector: jobs scheduled by requests with this correlation id, exact or run_8f2:*.
kind[], stateNarrowing: only these kinds, only this state.
(neither)Due now. The default.
byISO 8601 duration (PT2H): move each job this much earlier, bounded by its remaining delay.
to_remainingISO 8601 duration: leave this much on each job's clock (PT1H = due in an hour). A job due sooner is not moved. Not together with by.
waittrue: return once every moved job is terminal, or after 25 s; terminal in the response says which.
  • The timeline moves with the job, in the same transaction: a report's ready_at, a dispute's respond_by and review end, a pending action's completes_at, an evidence file's processing_until. The job and the resource record fast_forwarded_by.
  • Moved jobs go ahead of the workspace's other due work.
  • The response is {count, matched: [{id, kind, target, state, run_at}], terminal}: jobs already running or finished are listed with their state instead of silently missing. At most 500 may match (else 400 too_many_matches); another workspace's ids match nothing. Recorded as scheduled_job.fast_forward.
  • POST /v1/dev/advance-lifecycle with as_ofalso fast-forwards the targeted disputes' scheduled lifecycle jobs due by then.
  • Simulated webhook retries (fail_first_n) follow the normal retry schedule; fast-forward does not move them.

Cancel

POST /v1/scheduled-jobs/:id/cancel cancels one job; POST /v1/scheduled-jobs/cancel takes the same selectors and narrowing as fast-forward (bulk, at most 500). Only scheduled and running jobs are canceled; finished ones are listed with their state and count says how many were canceled. Recorded as scheduled_job.cancel. What happens to the target depends on the kind:

KindAfter a cancel
report.buildThe report ends failed with error_code: canceled.
dispute.deadline, dispute.rule, dispute.escalateThe dispute's clock stops: it becomes lifecycle_mode: manual (audited dispute.lifecycle_stop); only a targeted advance-lifecycle moves it.
dispute.complete_actionThe pending action is dropped, the status unchanged (dispute.cancel_action).
evidence.processThe file ends rejected with rejection_reason: processing_canceled.
webhook_delivery.releaseThe held delivery is dropped (failed, release_canceled).
generate.run, generate.trickle_batchThe generate job ends canceled and keeps the rows its committed chunks made (clean them up as usual).

The example's global teardown cancels every job its run left behind, by the run's correlation prefix:

examples/e2e-playwright/src/flows.ts (excerpt)
export async function cancelRun(api: DmApi): Promise<number> {
  const pending = await listScheduledJobs(api, { correlation_id: RUN_CORRELATION_FILTER, state: 'scheduled' });
  let canceled = 0;
  for (let i = 0; i < pending.length; i += 200) {
    const job_ids = pending.slice(i, i + 200).map((j) => j.id);
    const res = await api.post<ScheduledJobBulkResult>('/v1/scheduled-jobs/cancel', { job_ids });
    canceled += res.count;
  }
  return canceled;
}

How late a job may start

A due light job (deadlines, rulings, acknowledgements, evidence, webhook releases), including one you just fast-forwarded, starts within 5 seconds at the 99th percentile while the workspace has fewer than 200 due jobs, and within 15 seconds during a deploy. Heavy jobs (report builds, generate chunks) run one at a time per server. Each job reports its own lateness_ms.

Limits

Only non-default variants schedule work, so these never apply to a workspace that does not use flows. GET /v1/flows/limitslists every cap with the workspace's current usage and, when you send X-Correlation-Id, the usage of your correlation prefix.

LimitValueWhen exceeded
Delay of a report (queue + build), evidence processing, action acknowledgement, webhook holdat most 24 h400 invalid_flow_variant at profile save
Lifecycle windows and review30 s to 60 dayssame
Download link lifetime1 s to 7 dayssame
Pending report builds20 per workspace409 too_many_scheduled_jobs with Retry-After
Pending evidence processing100 per workspacesame
Pending action acknowledgements200 per workspacesame
Pending simulated webhook attempts (holds, duplicates, simulated failures)1,000 per workspaceThe delivery is stored failed with delivery_backlog_full
Scheduled lifecycle transitions20,000 per workspace per day (UTC)The dispute falls back to the hourly lifecycle, with an audit note
Lifecycle jobsone live job per dispute—
Delayed reads (api.latency) in flight10 per workspace per serverThe delay is skipped: X-DM-Flow-Warning: latency_budget
Profiles100 live per workspace, 16 KB each, 50 versions each409 flow_profile_limit
Live reports under report.limitsmax_live per run409 report_limit_reached (shown as reports_live)
  • Per-run sub-caps. One correlation prefix (the part of X-Correlation-Id before the first :) may hold at most half of each job cap (prefix_max in the response), so one CI run can never exhaust the workspace for the others.
  • Freeing slots. A job holds its slot only while it is scheduled or running. A canceled job, and a fast-forwarded one once it has run, frees its slot at once; so does a deleted report. Cancel what your run no longer needs instead of letting it wait out its delay.
GET /v1/flows/limits (one entry)
{"correlation_prefix":"run-4812",
 "data":[{"key":"pending_report_builds","description":"Report builds waiting or running.",
          "per":"tenant","min":null,"max":20,"unit":"jobs","prefix_max":10,
          "usage":{"tenant":3,"correlation_prefix":1},"error_code":"too_many_scheduled_jobs"}, …]}

Simulated outcomes in the audit trail

Every audit row an applied variant changed carries a flow block, and it is a list and /wait filter (flow_profile_id=fp_…, with or without @N):

"flow": {
  "profile_id": "fp_01J9Z3…@2",
  "profile_source": "header",            // header | account | tenant_default
  "variants": {                          // only the non-default keys that changed this action
    "dispute.action_failure": {"value": {"actions":["challenge"],"mode":"always","error":"scheme_unavailable"},
                               "source": "request"}  // request | stamped
  }
}
  • flow.profile_source says where the profile came from: header (X-DM-Flow), account(the synthetic account's assigned profile) or tenant_default. Each variant's source says whether it was read from this request (request) or from the resource's snapshot (stamped).
  • Under fp_platform (and for everything a variant did not change) the row has no flow block; platformappears only as a snapshot's source on resources.
  • Simulated failures are rows with outcome: "failed" and a simulated_* error_code (simulated_scheme_unavailable, simulated_service_unavailable, simulated_rate_limited…), so a test asserts on the failure it asked for and never confuses it with a real one. A simulated timeout after commit writes the normal success row with metadata.simulated_timeout: true.
  • Work a scheduled job does later (report.build, evidence.process, dispute.complete_action, dispute.rule, dispute.escalate…) is a system row under the correlation id of the request that scheduled it, so correlation_id=<yours> finds it.
  • Flow management itself is audited: flow_profile.create, flow_profile.update (a per-key diff), flow_profile.delete, tenant_flow_default.update, scheduled_job.fast_forward, scheduled_job.cancel, scheduled_job.expire, synthetic_account.expire_sessions and synthetic_account.flow_profile_released. See the action catalogue.
examples/e2e-playwright/tests/api/12-timeout-after-commit.spec.ts · show the file
import { randomUUID } from 'node:crypto';
import { expect, test } from '../../src/dm';
import { uploadEvidence } from '../../src/evidence';
import type { Dispute, DisputePage, ErrorBody } from '../../src/types';

// (3) "Did it land?": the challenge is committed, then the API answers
// 504 as if the connection dropped. A client that retries with the same
// Idempotency-Key gets the original success back, and the dispute changed
// status exactly once.
test('a timed-out challenge retried with the same Idempotency-Key changes the status once', async ({ dm }) => {
  const { job } = await dm.generate({ count: 12, dispute_rate: 1, seed: 42 });
  const page = await dm.api.get<DisputePage>('/v1/disputes', {
    generate_job_id: job.id,
    status: 'evidence_required',
    page_size: 1,
  });
  expect(page.items).toHaveLength(1);
  const dispute = page.items[0];
  await uploadEvidence(dm, dispute.id);

  const timeout = await dm.flowProfile('challenge-timeout', {
    'dispute.action_failure': { actions: ['challenge'], mode: 'timeout_after_commit' },
  });
  await dm.useFlow(timeout.ref);
  const mark = await dm.mark();
  const key = randomUUID();
  const call = { data: { note: 'see the receipt' }, headers: { 'Idempotency-Key': key } };

  const first = await dm.api.fetch<ErrorBody>('POST', `/v1/disputes/${dispute.id}/challenge`, call);
  expect(first.status).toBe(504);
  expect(first.body.code).toBe('simulated_timeout');

  const retry = await dm.api.fetch<Dispute>('POST', `/v1/disputes/${dispute.id}/challenge`, call);
  expect(retry.status).toBe(201);
  expect(retry.headers['idempotent-replayed']).toBe('true');
  expect(retry.body).toMatchObject({ id: dispute.id, status: 'under_review' });

  const challenges = await dm.expectSequence(
    { after_seq: mark, correlation_id: dm.correlationId, action: 'dispute.challenge' },
    [
      {
        action: 'dispute.challenge',
        outcome: 'success',
        target: { id: dispute.id },
        changes: { status: { from: 'evidence_required', to: 'under_review' } },
        metadata: { simulated_timeout: true },
        flow: { profile_id: timeout.ref, profile_source: 'header' },
      },
    ],
    { mode: 'exact' },
  );
  expect(challenges).toHaveLength(1);
});
examples/e2e-playwright/tests/api/13-evidence-rejected.spec.ts · show the file
import { expect, test } from '../../src/dm';
import { uploadEvidence } from '../../src/evidence';
import type { DisputePage, ErrorBody, Evidence } from '../../src/types';

// (4) A virus scan that takes half an hour and then rejects the file. While
// the file is processing, a challenge is refused (it would submit nothing);
// once it is rejected the dispute has no usable evidence at all.
test('evidence processing then rejected blocks the challenge', async ({ dm }) => {
  const { job } = await dm.generate({ count: 12, dispute_rate: 1, seed: 42 });
  const page = await dm.api.get<DisputePage>('/v1/disputes', {
    generate_job_id: job.id,
    status: 'evidence_required',
    page_size: 1,
  });
  expect(page.items).toHaveLength(1);
  const dispute = page.items[0];

  const scan = await dm.flowProfile('evidence-virus-30m', {
    'evidence.processing': { delay_s: 1800, outcome: 'rejected', reason: 'virus_detected' },
  });
  await dm.useFlow(scan.ref);
  const mark = await dm.mark();
  const evidence = await uploadEvidence(dm, dispute.id);
  expect(evidence).toMatchObject({ status: 'processing', flow: { source: 'header' } });

  const early = await dm.api.fetch<ErrorBody>('POST', `/v1/disputes/${dispute.id}/challenge`, { data: {} });
  expect(early.status).toBe(409);
  expect(early.body).toMatchObject({ code: 'evidence_processing', details: { processing_count: 1 } });

  const moved = await dm.fastForward({ target_id: evidence.id });
  expect(moved.jobs.map((j) => [j.kind, j.state])).toEqual([['evidence.process', 'succeeded']]);
  const [processed] = await dm.waitFor({ after_seq: mark, action: 'evidence.process', target_id: evidence.id });
  expect(processed).toMatchObject({
    outcome: 'success',
    changes: { status: { from: 'processing', to: 'rejected' } },
    metadata: { rejection_reason: 'virus_detected', file_deleted: true },
  });

  const after = await dm.api.get<{ evidence: Evidence[] }>(`/v1/disputes/${dispute.id}`);
  expect(after.evidence).toEqual([expect.objectContaining({ id: evidence.id, status: 'rejected', rejection_reason: 'virus_detected' })]);
  const late = await dm.api.fetch<ErrorBody>('POST', `/v1/disputes/${dispute.id}/challenge`, { data: {} });
  expect(late.status).toBe(400);
  expect(late.body.code).toBe('evidence_missing');
  const download = await dm.api.fetch<ErrorBody>('GET', `/v1/disputes/${dispute.id}/evidence/${evidence.id}/download-url`);
  expect(download.status).toBe(409);
  expect(download.body).toMatchObject({ code: 'evidence_not_available', details: { status: 'rejected' } });
});

Permissions

PermissionWhoAPI keysAllows
flow.readOwner onlyYes: keys the owner minted with the E2E presetHonoured X-DM-Flow and dm_flow; GET /v1/flows/catalogue, GET /v1/flows/limits, GET /v1/flow-profiles[/:id[/versions]], GET /v1/scheduled-jobs[/:id]; flow_profile in synthetic-account reads.
flow.writeOwner onlyYes: keys the owner minted with the E2E presetCreate, change and archive profiles; fast-forward and cancel jobs; POST /v1/team/synthetic-accounts/:id/expire-sessions; flow_profile on synthetic-account create and edit (with team.write).
flow.default.writeOwner onlyNeverPUT /v1/flow-profiles/default: it changes every resource created without a profile.

A key holds the scopes it was given that its creator still holds, so only keys the owner minted can carry a flow scope. Every other role gets 403 missing_permission on every flow route, and no synthetic account role can be given flow.read or flow.write.

expire-sessions vs revoke-sessions

expire-sessionsrevoke-sessions
RoutePOST /v1/team/synthetic-accounts/:id/expire-sessionsPOST /v1/team/synthetic-accounts/:id/revoke-sessions
PurposeA simulation: test how your client handles an expired session.A security action: cut off an account now.
Whoflow.write: the owner, or an owner-minted E2E keyThe owner’s signed-in session only; never a key
EffectTokens issued up to now answer 401 session_expired on their next requestEvery session answers 401 session_revoked
Auditsynthetic_account.expire_sessionssynthetic_account.revoke_sessions

For sessions that always expire after N seconds, assign the account a profile with session.max_age instead.