API reference
Flows and scheduled jobs
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 withPOST /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/:idwrites a new version with the completevariants(not a merge) and movescurrent_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) orfp_x@3(version 3, forever). Every profile response carriesref, 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
namewith the samedescriptionandvariantsreturns the existing profile with 200; a different body is409 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/:idarchives it and frees the name at once. Requests that still name it keep working for 24 hours withX-DM-Flow-Warning: archived, then get400 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_variantwithdetails: [{field, constraints}], at save time, never at request time.
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 cookie | Where | Meaning |
|---|---|---|
X-DM-Flow | Request | The 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_flow | Cookie on the portal host | Set 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-Applied | Every response | The profile this request, and any resource it created, used: fp_…@N, or fp_platform. The portal copies it back. |
X-DM-Flow-Rejected | Portal responses | Why 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-Warning | API responses | Comma-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:
| Scope | Read | Keys |
|---|---|---|
request | From 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 |
stamped | Once, 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:
X-DM-Flow, when the caller may opt in (the owner, or an owner-minted key withflow.read). Profile sourceheader.- For a request authenticated as a synthetic account: the profile the owner assigned to that account. Source
account. - The workspace default. Source
tenant_default. fp_platform, today's behaviour. Sourceplatform.
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:
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"}}}, …}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.buildrow carriesflow: {profile_id: "fp_A@1", profile_source: "header", variants: {"report.delivery": {"value": {…}, "source": "stamped"}}}; thereport.create_download_urlrow carriesflow: {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, scope | Bounds and default | What it tests | Emits |
|---|---|---|---|
report.delivery POST /v1/reports scope | mode: sync | async delay_s?: 0–86400 build_s?: 0–86400 default: | 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: audit: webhooks: |
report.outcome POST /v1/reports scope | mode: ok | fail | fail_then_ok | empty | truncated error_code?: report_timeout | report_too_large | internal n?: 1–3 row_limit?: 1–10000 default: | 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: audit: webhooks: |
report.download_url GET /v1/reports/:id/download-url scope | ttl_s: 1–604800 or mode: "expired" default: | 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: webhooks: none |
report.limits POST /v1/reports scope | 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: audit: webhooks: none |
lifecycle.ruling POST /v1/dev/generate scope | mode: random | always_won | always_lost | probability p?: 0–1 seed?: 0–2147483647 default: | 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: webhooks: |
lifecycle.windows POST /v1/dev/generate scope | 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: audit: webhooks: |
lifecycle.review POST /v1/dev/generate scope | 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: audit: webhooks: |
lifecycle.webhooks POST /v1/dev/generate scope | enabled: boolean default: | 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: webhooks: |
lifecycle.escalation POST /v1/dev/generate scope | 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: webhooks: |
dispute.action_failure POST /v1/disputes/:id/{accept,challenge,rfi-response,arbitrate} scope | 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: audit: webhooks: none |
dispute.action_ack POST /v1/disputes/:id/{challenge,arbitrate} scope | 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: audit: webhooks: |
api.latency GET (the listed routes) scope | 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 | 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: audit: webhooks: none |
evidence.download_url GET /v1/disputes/:id/evidence/:evidenceId/download-url scope | ttl_s: 1–604800 or mode: "expired" default: | 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: webhooks: none |
evidence.processing POST /v1/disputes/:id/evidence scope | 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: audit: webhooks: |
evidence.limits POST /v1/disputes/:id/evidence scope | 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: audit: webhooks: none |
generate.profile POST /v1/dev/generate scope | mix: realistic | actionable | terminal deadline_anchor: opened | now reason_codes?: list of 10 values currencies?: list of 6 values amount_edges?: boolean default: | 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: webhooks: none |
generate.trickle POST /v1/dev/generate scope | 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: webhooks: |
webhook.delivery Webhook deliveries to simulation subscribers scope | 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: audit: webhooks: none |
session.max_age Every request authenticated as a synthetic account scope | 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: audit: 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 value | Requests as the account use |
|---|---|
null | The workspace default (else today’s behaviour). The default for new accounts. |
fp_platform | Always today’s behaviour, whatever the workspace default is. |
fp_x | That profile’s current version, looked up on each request. |
fp_x@N | Version N, pinned. |
- Set it with
flow_profileonPOST /v1/team/synthetic-accountsand change it withPATCH /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 is400 invalid_flow_profile. - Owner only. Sending
flow_profile(any value,nullincluded) needsflow.writeon top ofteam.write; without it the whole request is403 missing_permissionand 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}(ornull), only to callers holdingflow.read; it is omitted for everyone else. Thesynthetic_account.createandsynthetic_account.updateaudit rows record it inchanges.flow_profile. - Audit rows of requests the account made, and the snapshots of what it created, carry
profile_source: "account"(header,account,tenant_defaultor, 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 carryX-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 asynthetic_account.flow_profile_releasedaudit 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):
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).
| Route | What |
|---|---|
GET /v1/scheduled-jobs | Newest 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/:id | One 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:
| Field | Meaning |
|---|---|
job_ids | Selector: up to 200 job ids. |
target_id | Selector: the jobs of one resource (rpt_…, dsp_…, evd_…, whd_…, gj_…). |
generate_job_id | Selector: every job of one generate job. |
correlation_id | Selector: jobs scheduled by requests with this correlation id, exact or run_8f2:*. |
kind[], state | Narrowing: only these kinds, only this state. |
(neither) | Due now. The default. |
by | ISO 8601 duration (PT2H): move each job this much earlier, bounded by its remaining delay. |
to_remaining | ISO 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. |
wait | true: 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'srespond_byand review end, a pending action'scompletes_at, an evidence file'sprocessing_until. The job and the resource recordfast_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 (else400 too_many_matches); another workspace's ids match nothing. Recorded asscheduled_job.fast_forward. POST /v1/dev/advance-lifecyclewithas_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:
| Kind | After a cancel |
|---|---|
report.build | The report ends failed with error_code: canceled. |
dispute.deadline, dispute.rule, dispute.escalate | The dispute's clock stops: it becomes lifecycle_mode: manual (audited dispute.lifecycle_stop); only a targeted advance-lifecycle moves it. |
dispute.complete_action | The pending action is dropped, the status unchanged (dispute.cancel_action). |
evidence.process | The file ends rejected with rejection_reason: processing_canceled. |
webhook_delivery.release | The held delivery is dropped (failed, release_canceled). |
generate.run, generate.trickle_batch | The 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:
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.
| Limit | Value | When exceeded |
|---|---|---|
| Delay of a report (queue + build), evidence processing, action acknowledgement, webhook hold | at most 24 h | 400 invalid_flow_variant at profile save |
| Lifecycle windows and review | 30 s to 60 days | same |
| Download link lifetime | 1 s to 7 days | same |
| Pending report builds | 20 per workspace | 409 too_many_scheduled_jobs with Retry-After |
| Pending evidence processing | 100 per workspace | same |
| Pending action acknowledgements | 200 per workspace | same |
| Pending simulated webhook attempts (holds, duplicates, simulated failures) | 1,000 per workspace | The delivery is stored failed with delivery_backlog_full |
| Scheduled lifecycle transitions | 20,000 per workspace per day (UTC) | The dispute falls back to the hourly lifecycle, with an audit note |
| Lifecycle jobs | one live job per dispute | — |
| Delayed reads (api.latency) in flight | 10 per workspace per server | The delay is skipped: X-DM-Flow-Warning: latency_budget |
| Profiles | 100 live per workspace, 16 KB each, 50 versions each | 409 flow_profile_limit |
Live reports under report.limits | max_live per run | 409 report_limit_reached (shown as reports_live) |
- Per-run sub-caps. One correlation prefix (the part of
X-Correlation-Idbefore the first:) may hold at most half of each job cap (prefix_maxin 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
scheduledorrunning. 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.
{"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_sourcesays where the profile came from:header(X-DM-Flow),account(the synthetic account's assigned profile) ortenant_default. Each variant'ssourcesays 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 noflowblock;platformappears only as a snapshot'ssourceon resources. - Simulated failures are rows with
outcome: "failed"and asimulated_*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 withmetadata.simulated_timeout: true. - Work a scheduled job does later (
report.build,evidence.process,dispute.complete_action,dispute.rule,dispute.escalate…) is asystemrow under the correlation id of the request that scheduled it, socorrelation_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_sessionsandsynthetic_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
| Permission | Who | API keys | Allows |
|---|---|---|---|
flow.read | Owner only | Yes: keys the owner minted with the E2E preset | Honoured 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.write | Owner only | Yes: keys the owner minted with the E2E preset | Create, 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.write | Owner only | Never | PUT /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-sessions | revoke-sessions | |
|---|---|---|
| Route | POST /v1/team/synthetic-accounts/:id/expire-sessions | POST /v1/team/synthetic-accounts/:id/revoke-sessions |
| Purpose | A simulation: test how your client handles an expired session. | A security action: cut off an account now. |
| Who | flow.write: the owner, or an owner-minted E2E key | The owner’s signed-in session only; never a key |
| Effect | Tokens issued up to now answer 401 session_expired on their next request | Every session answers 401 session_revoked |
| Audit | synthetic_account.expire_sessions | synthetic_account.revoke_sessions |
For sessions that always expire after N seconds, assign the account a profile with session.max_age instead.