API reference

Webhooks

Subscribe a URL to dispute events and dispute.markets POSTs each one to it, signed. Every delivery is also an audit event, so a test can count deliveries exactly instead of guessing.

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).

201 Created
{ "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

RouteWhat it does
GET /v1/webhooksYour 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/:idChange url, events, enabled, accepts_simulation or the run filter (null removes it). Scope webhook.write.
DELETE /v1/webhooks/:idRemove it; answers 204. Scope webhook.write.
POST /v1/webhooks/:id/testBody {"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

POST to your URL
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.

HeaderMeaning
X-Dispute-SignatureHex HMAC-SHA256 of the raw request body, keyed with the webhook’s signing secret.
X-Dispute-EventThe event type, the same as the body’s event.
X-Dispute-Delivery-IdThis 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-AttemptWhich attempt this is, 1 to 5. A retry carries the byte-identical body (same delivered_at), so the same signature.
X-Dispute-Request-IdThe API request that caused the event (for a test event, the test request).
X-Dispute-Correlation-IdThat 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

Node.js
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.send with outcome failed.
  • A failed attempt is retried when a later one may succeed: no response (timeout, connection refused), 408, 409, 425, 429 or any 5xx. 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.send event with metadata.attempt, metadata.retry_scheduled and metadata.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 many webhook_delivery.send events. Against a receiver that fails, count the events with metadata.attempt = 1, or wait for the one whose outcome is success or whose retry_scheduled is 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:

  1. Subscribed? The event type is in the webhook's events (or events is empty).
  2. In scope? A webhook (or dev sink) with a run filter gets only events whose triggering request carried an X-Correlation-Id starting with correlation_prefix, and/or that belong to generate job generate_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.
  3. 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.
EventSent whenWho receives it
dispute.status_changed, dispute.evidence_added, dispute.note_addedAn action on a dispute (API or portal)Every subscribed webhook in scope
dispute.createdPOST /v1/webhooks/:id/testThat webhook only
dispute.status_changedA lifecycle transition (expiry, ruling, escalation) of a dispute generated with lifecycle.webhooks enabledSimulation subscribers in scope
dispute.createdEach 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.readyAn asynchronous report (report.delivery async) ends done or failedSimulation subscribers in scope
evidence.processedAn evidence file under evidence.processing becomes available or rejected, on a dispute created with lifecycle.webhooks enabledSimulation subscribers in scope
report.ready (data)
{"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":"…"}}
evidence.processed (data)
{"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.

FieldMeaning
accepts_simulationSet 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.
filterRun 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_deliveriesIn 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.

FieldEffect
delay_s0 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.
duplicates1 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.
signatureinvalid signs with a random key; missing leaves X-Dispute-Signature out. Your receiver should reject both.
fail_first_n0 to 5 attempts recorded as failed (error_code: simulated_failure) without sending, then retried on the normal schedule (1, 5, 15, 40 minutes).
droptrue: stored failed with simulated_drop and never sent. Reconcile by polling the API.
reorder_window_s0 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:

examples/e2e-playwright/tests/api/05-webhook-sink.spec.ts
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);
});