API reference
Webhooks
Subscribe
curl -s -X POST https://api.dispute.markets/v1/webhooks \
-H "Authorization: Bearer $DM_API_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: create-webhook-ci-4812" \
-d '{"url":"https://hooks.example.com/dispute","events":["dispute.status_changed"]}'Events: dispute.status_changed, dispute.evidence_added, dispute.note_added and dispute.created, plus the opt-in types report.ready and evidence.processed. Leave events out (or empty) to receive all of them. Every webhook gets events for the actions you take on a dispute through the API or the portal (accept, challenge, escalate and RFI responses send dispute.status_changed; evidence sends dispute.evidence_added; notes, decline-list and support-case send dispute.note_added), and dispute.created from POST /v1/webhooks/:id/test. Generating disputes, the lifecycle (deadlines expiring, rulings), finished reports and processed evidence send webhooks only when a flow profile turns them on, and then only to simulation subscribers.
The response holds the signing secret (whsec_…) once; keep it with your receiver. A webhook created by an API key is tied to that key: when the key is revoked or expires, the webhook is disabled (webhook.disable).
{ "id": "wh_…", "tenant_id": "…", "url": "https://hooks.example.com/dispute",
"events": ["dispute.status_changed"], "enabled": true,
"accepts_simulation": false, "filter": null, "recent_deliveries": [],
"last_attempted_at": null, "last_delivered_at": null, "last_error": null,
"created_by_key_id": "tak_…", "key_expires_at": "…",
"created_at": "…", "updated_at": "…",
"secret": "whsec_…" }Manage
| Route | What it does |
|---|---|
GET /v1/webhooks | Your webhooks, oldest first, in the shape above without secret. last_attempted_at, last_delivered_at and last_error show how the latest delivery went. Scope webhook.read. |
PATCH /v1/webhooks/:id | Change url, events, enabled, accepts_simulation or the run filter (null removes it). Scope webhook.write. |
DELETE /v1/webhooks/:id | Remove it; answers 204. Scope webhook.write. |
POST /v1/webhooks/:id/test | Body {"payload": "…"} (required text, echoed as data.payload). Sends a dispute.created event with data.test: true to this webhook only, even when it is disabled, and answers {"ok": true, "status_code": 200} once your endpoint replies (10 second limit). An unknown id is 404 webhook_not_found (until 2026-10-29 it answered 200 with {"ok": false}). Your other webhooks receive nothing. Test sends are not retried. Scope webhook.write. |
A delivery
Content-Type: application/json
User-Agent: dispute.markets-webhooks/1
X-Dispute-Event: dispute.status_changed
X-Dispute-Signature: 5f0c…e91a
X-Dispute-Delivery-Id: whd_01JBQA…
X-Dispute-Attempt: 1
X-Dispute-Request-Id: req_01JBQ9…
X-Dispute-Correlation-Id: ci-4812:accept-dispute
{"event":"dispute.status_changed","delivered_at":"2026-10-23T09:14:03.611Z",
"sequence":184467,
"data":{"dispute_id":"dsp_…","status":"accepted","message":"…","metadata":{…},
"actor_sub":"tak_…","created_at":"2026-10-23T09:14:03.498Z"}}Until the camel case aliases are retired (see names), a body also carries the old camel case keys next to the snake case ones, with the same values (deliveredAt, disputeId, actorSub, createdAt). Read the snake case keys.
| Header | Meaning |
|---|---|
X-Dispute-Signature | Hex HMAC-SHA256 of the raw request body, keyed with the webhook’s signing secret. |
X-Dispute-Event | The event type, the same as the body’s event. |
X-Dispute-Delivery-Id | This delivery (whd_…), the same on every retry of it: dedupe on it. The matching webhook_delivery.send audit events have it as metadata.delivery_id. |
X-Dispute-Attempt | Which attempt this is, 1 to 5. A retry carries the byte-identical body (same delivered_at), so the same signature. |
X-Dispute-Request-Id | The API request that caused the event (for a test event, the test request). |
X-Dispute-Correlation-Id | That request’s X-Correlation-Id, when it had one. |
sequencein the body is a number that grows with every delivery dispute.markets creates, across the platform. Order events by it, never by arrival time. It has gaps (other workspaces' deliveries take numbers too), so do not expect consecutive values. A retry or duplicate copy carries the same body, so the same sequence. In the rare case the counter cannot be read, a delivery is still sent, with sequence: null; order such an event by delivered_at.
data.actorSub may be an API key id (tak_…) when a key caused the event; otherwise it is the acting person's or synthetic account's user id. Do not assume it is a person.
Verify the signature
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody: Buffer, signature: string, secret: string): boolean {
const expected = createHmac('sha256', secret).update(rawBody).digest();
const given = Buffer.from(signature, 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
}Compute it over the bytes you received, before parsing the JSON.
The signature covers the body only; there is no signed timestamp header. To limit replays of a captured delivery, store the X-Dispute-Delivery-Id values you have processed and ignore a repeat, and reject a delivered_atolder than you are willing to accept (retries keep the first attempt's delivered_at, and the last retry lands about an hour after it).
Delivery rules
- The first attempt is sent right after the action commits, with a 10 second timeout.
- Any non-2xx answer, a redirect (never followed) or a timeout is a failed attempt:
webhook_delivery.sendwith outcomefailed. - A failed attempt is retried when a later one may succeed: no response (timeout, connection refused),
408,409,425,429or any5xx. Other answers (4xx, a redirect) and URLs refused by the address check are final. - At most 5 attempts per delivery, about 1, 5, 15 and 40 minutes apart (±20 % jitter), so the last one lands roughly an hour after the event. A webhook disabled or deleted in the meantime gets no further attempts.
- Every attempt is its own
webhook_delivery.sendevent withmetadata.attempt,metadata.retry_scheduledandmetadata.next_attempt_at, under the triggering request's correlation id. - The event that triggered deliveries says how many it scheduled in
metadata.webhooks_scheduled, so a test against a receiver that answers 2xx (a dev sink always does) can wait for exactly that manywebhook_delivery.sendevents. Against a receiver that fails, count the events withmetadata.attempt= 1, or wait for the one whose outcome issuccessor whoseretry_scheduledis false.
Who receives what (fan-out rules)
When an event happens, dispute.markets decides for each enabled webhook of the workspace whether it gets a delivery, and how:
- Subscribed? The event type is in the webhook's
events(oreventsis empty). - In scope? A webhook (or dev sink) with a run
filtergets only events whose triggering request carried anX-Correlation-Idstarting withcorrelation_prefix, and/or that belong to generate jobgenerate_job_id. Events a scheduled job causes later (a ruling, a finished report) keep the correlation id of the request that scheduled them, so they stay in the run's scope. No filter: every event. - Simulated? Opt-in event types and anything a flow variant changes go only to simulation subscribers (
accepts_simulation: true). Every other webhook receives the normal event types, delivered normally, exactly as before flows existed.
| Event | Sent when | Who receives it |
|---|---|---|
dispute.status_changed, dispute.evidence_added, dispute.note_added | An action on a dispute (API or portal) | Every subscribed webhook in scope |
dispute.created | POST /v1/webhooks/:id/test | That webhook only |
dispute.status_changed | A lifecycle transition (expiry, ruling, escalation) of a dispute generated with lifecycle.webhooks enabled | Simulation subscribers in scope |
dispute.created | Each dispute a generate job creates, with lifecycle.webhooks enabled (at most 200 per job; generate.trickle sends them batch by batch) | Simulation subscribers in scope |
report.ready | An asynchronous report (report.delivery async) ends done or failed | Simulation subscribers in scope |
evidence.processed | An evidence file under evidence.processing becomes available or rejected, on a dispute created with lifecycle.webhooks enabled | Simulation subscribers in scope |
{"event":"report.ready","delivered_at":"…","sequence":184502,
"data":{"id":"rpt_…","object":"report","status":"done","row_count":1840,"truncated":false,
"error_code":null,"attempt":1,"completed_at":"…"}}{"event":"evidence.processed","delivered_at":"…","sequence":184511,
"data":{"id":"evd_…","object":"evidence","dispute_id":"dsp_…","status":"rejected",
"rejection_reason":"virus_detected","processed_at":"…"}}The webhook.delivery variant (below) is read from the request that triggered the event; for events without one (lifecycle, dispute.created after generate, report.ready, evidence.processed) it is the value stamped on the dispute, generate job, report or evidence file when it was created. Every delivery, simulated or not, carries sequence; order by it and expect gaps.
Simulation subscribers and run scope
Some deliveries exist only for testing: the opt-in event types (report.ready, evidence.processed, lifecycle dispute.status_changed and dispute.created after a generate, all enabled by flow variants) and the webhook.delivery flow variant (delays, duplicates, bad signatures, simulated failures and drops, reordering). They go only to simulation subscribers: webhooks with accepts_simulation: true. Every other webhook keeps the normal delivery of the normal event types, so your real staging receiver never sees simulated traffic.
| Field | Meaning |
|---|---|
accepts_simulation | Set on POST or PATCH /v1/webhooks. Defaults to true when the URL is one of your dev webhook sinks and false otherwise. Set it false on a sink webhook to test what an ordinary receiver gets. |
filter | Run scope, on a webhook or a dev sink (POST /v1/dev/webhook-sinks with {"filter": {…}}): correlation_prefix receives only events whose triggering X-Correlation-Id starts with it, generate_job_id only events of that generate job. Both set: both must match. A scoped sink never sees another CI run's deliveries. null receives everything. |
recent_deliveries | In GET /v1/webhooks: the last 5 deliveries of each webhook, newest first, with status (held, pending, succeeded, failed), attempt, copies_sent, sequence, error_code and the variant applied. |
The webhook.delivery variant
Set it in a flow profile and send the profile with the request that causes the event (X-DM-Flow); events without a request use the value stamped on the dispute, generate job, report or evidence file when it was created.
| Field | Effect |
|---|---|
delay_s | 0 to 86,400. The delivery is stored held and released when the delay ends. Fast-forward it with POST /v1/scheduled-jobs/fast-forward and {"target_id": "whd_…"} to release it at once. |
duplicates | 1 to 3. The same delivery id and byte-identical body are sent that many times, duplicate_gap_s (0 to 300) apart. Your receiver should dedupe on X-Dispute-Delivery-Id. |
signature | invalid signs with a random key; missing leaves X-Dispute-Signature out. Your receiver should reject both. |
fail_first_n | 0 to 5 attempts recorded as failed (error_code: simulated_failure) without sending, then retried on the normal schedule (1, 5, 15, 40 minutes). |
drop | true: stored failed with simulated_drop and never sent. Reconcile by polling the API. |
reorder_window_s | 0 to 60. A dispute's deliveries to one webhook within the window are held and released together, newest first. Order them by sequence. |
A workspace holds at most 1,000 pending simulated attempts (held deliveries, duplicate copies and simulated failures still to come). Past that a simulated delivery is stored failed with delivery_backlog_full. Normal deliveries never count.
examples/e2e-playwright/tests/api/14-duplicate-webhook.spec.ts · show the file
import { expect, test } from '../../src/dm';
import type { DisputePage } from '../../src/types';
// (5) At-least-once delivery: the same webhook arrives twice. A receiver
// must deduplicate on the delivery id; the sink shows both arrivals carry
// the same one, both validly signed.
test('a duplicate-delivery profile sends two copies with one delivery id', async ({ dm }) => {
const { sink, webhook } = await dm.sink({ events: ['dispute.status_changed'] });
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);
// `webhook.delivery` is read from the request that triggers the event.
const twice = await dm.flowProfile('webhook-twice', { 'webhook.delivery': { duplicates: 2, duplicate_gap_s: 0 } });
await dm.useFlow(twice.ref);
const mark = await dm.mark();
await dm.api.post(`/v1/disputes/${page.items[0].id}/accept`, {});
const received = await dm.waitFor(
{ after_seq: mark, action: 'webhook_sink.receive', target_id: sink.id, correlation_id: dm.correlationId },
{ minCount: 2, timeout: 30_000 },
);
expect(received).toHaveLength(2);
const ids = received.map((r) => r.metadata?.delivery_id);
expect(ids[0]).toBeTruthy();
expect(ids[1]).toBe(ids[0]);
expect(received.every((r) => r.metadata?.signature_valid === true)).toBe(true);
const sends = await dm.waitFor(
{ after_seq: mark, action: 'webhook_delivery.send', target_id: webhook.id, correlation_id: dm.correlationId },
{ minCount: 2 },
);
expect(sends.map((s) => [s.outcome, s.metadata?.copy, s.metadata?.simulated])).toEqual([
['success', 1, true],
['success', 2, true],
]);
});Receivers must be publicly reachable
Webhook URLs must be https and resolve only to public addresses. Private, loopback, link-local, cloud-metadata, CGNAT and unique-local IPv6 addresses, and .local / .internal hosts, are refused with 400 webhook_url_not_allowed, when you create or update the webhook and again, with a fresh DNS lookup, on every delivery. A CI job on localhost therefore cannot receive webhooks directly. Use a dev sink: on api.dispute.markets itself, only the URL of one of your own live dev sinks is accepted.
Dev webhook sinks (for tests)
POST /v1/dev/webhook-sinks (scope dev.generate) returns a receiver URL on our own host, valid for 24 hours:
{ "id": "whs_…",
"url": "https://api.dispute.markets/v1/dev/webhook-sinks/whs_…/receive",
"expires_at": "…", "filter": null }Pass {"filter": {"correlation_prefix": "run-4812"}} to scope the sink to one run. Point a webhook at that URL (it is a simulation subscriber unless you set accepts_simulation: false). Every delivery it receives is recorded in your trail as webhook_sink.receive (target the sink), with metadata.delivery_id, event, body_sha256, signature_valid(the signature checked against your webhook's secret) and the headers minus the signature, under the triggering request's correlation id. At most 10 live sinks per workspace: retire a sink when your test is done with DELETE /v1/dev/webhook-sinks/:id (recorded as webhook_sink.delete; retiring an expired sink is a no-op).
An eleventh create answers 409 webhook_sink_limit, and a sink nobody retires keeps its slot until it expires 24 hours after creation. So retire it in teardown, not at the end of the test body: a suite that only cleans up on success fills all 10 slots after a few failing runs and then fails every run for a day. The example registers the delete with dm.onCleanup right after creating the sink, which runs even when the test fails.
The example's webhook spec, end to end:
import { expect, test } from '../../src/dm';
import type { AuditEvent, DisputePage } from '../../src/types';
// (5) Webhooks without a public receiver: a dev sink records what it
// receives in the trail, under the correlation id of the request that
// caused the delivery. `dm.sink()` scopes the sink and its webhook to
// this run's correlation prefix (another CI job's events never reach
// it) and deletes both after the test, freeing the sink's slot (10 live
// per workspace).
test('webhook deliveries to a dev sink are counted and signed', async ({ dm }) => {
const { sink, webhook } = await dm.sink({ events: ['dispute.status_changed'] });
// A test event goes to exactly this webhook.
let mark = await dm.mark();
const sent = await dm.api.post<{ ok: boolean }>(`/v1/webhooks/${webhook.id}/test`, { payload: dm.correlationId });
expect(sent.ok).toBe(true);
const [testRow] = await dm.waitFor({ after_seq: mark, correlation_id: dm.correlationId, action: 'webhook.test' });
// Filtered by correlation id too: other tests of this run reach the sink as well.
const [testReceive] = await dm.waitFor({
after_seq: mark,
action: 'webhook_sink.receive',
target_id: sink.id,
correlation_id: dm.correlationId,
});
expect(testReceive.metadata).toMatchObject({
signature_valid: true,
delivery_id: testRow.metadata?.delivery_id,
});
// A real event fans out to every subscribed webhook in the workspace
// whose run scope it matches; the triggering event says how many.
const { job } = await dm.generate({ count: 12, dispute_rate: 1, seed: 42 });
const list = await dm.api.get<DisputePage>('/v1/disputes', {
generate_job_id: job.id,
status: 'evidence_required',
page_size: 1,
});
expect(list.items).toHaveLength(1);
mark = await dm.mark();
await dm.api.post(`/v1/disputes/${list.items[0].id}/accept`, {});
const [accept] = await dm.waitFor({ after_seq: mark, correlation_id: dm.correlationId, action: 'dispute.accept' });
const scheduled = accept.metadata?.webhooks_scheduled as number;
expect(scheduled).toBeGreaterThanOrEqual(1);
// Ours is one of them. (Exactly `scheduled` sends is not a safe count
// while other tests run: another test may delete its webhook between
// the fan-out and the send, and that delivery is then dropped.)
const [ours] = await dm.waitFor(
{ after_seq: mark, correlation_id: dm.correlationId, action: 'webhook_delivery.send', target_id: webhook.id },
{ timeout: 30_000 },
);
const received = await dm.waitFor({
after_seq: mark,
action: 'webhook_sink.receive',
target_id: sink.id,
correlation_id: dm.correlationId,
});
// Nothing beyond what was scheduled, one delivery to our webhook and one arrival at our sink.
const all = await dm.events({ after_seq: mark, correlation_id: dm.correlationId, action: 'webhook_delivery.send' });
expect(all.length).toBeLessThanOrEqual(scheduled);
expect(all.filter((d: AuditEvent) => d.target?.id === webhook.id)).toHaveLength(1);
expect(received).toHaveLength(1);
expect(received[0].metadata).toMatchObject({ signature_valid: true, delivery_id: ours?.metadata?.delivery_id });
expect(received[0].request.correlation_id).toBe(dm.correlationId);
});