Events

Subscribe to state changes without polling the protocol.

OpenDoc exposes live SSE streams and HMAC-signed webhooks for canonical protocol events. Delivery is at-least-once from a transactional outbox: events are written in the same database transaction as the state change they describe, and webhooks are retried on an exponential backoff schedule. Event payloads carry entity IDs and state metadata, not broad PHI.

SSEGET /events
WebhooksPOST /event-subscriptions
Deliveryat-least-once
Signaturet=<unix>,v1=<hex>

Event types

Discover supported events from /protocol.

The event vocabulary is returned by the protocol discovery endpoint. Use it instead of hardcoding assumptions when building an integration.

curl https://api.opendoc.com/protocol | jq '.events.types'

SSE

Use SSE for live agent or dashboard connections.

The SSE stream requires an agent token. Server clients should use Authorization. Browser EventSource clients can pass the token as a query parameter because EventSource does not allow custom headers.

The stream is permission-filtered by your token's scopes: transaction.* and booking events require READ_TRANSACTIONS or READ_BOOKINGS; health_key.* requires READ_HEALTH_KEY; receipt.* requires READ_RECEIPTS; provider.* and market.* are delivered to any valid token; agent_ecosystem.revoked is always delivered. Webhook dispatch applies the same filter to the subscription owner's token.

curl https://api.opendoc.com/events \
  -H "Authorization: Bearer $OPENDOC_AGENT_TOKEN"

const stream = new EventSource(
  "https://api.opendoc.com/events?token=" + encodeURIComponent(agentToken)
);

stream.addEventListener("transaction.state_changed", (event) => {
  const payload = JSON.parse(event.data);
  console.log(payload);
});

Webhooks

Register a callback for server-to-server delivery.

Webhook subscriptions are owned by the calling agent token. The signing secret is returned once when the subscription is created.

curl https://api.opendoc.com/event-subscriptions \
  -X POST \
  -H "Authorization: Bearer $OPENDOC_AGENT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "callbackUrl": "https://partner.example.com/opendoc/webhooks",
    "eventTypes": ["transaction.state_changed", "receipt.finalized"]
  }'

Payload versioning

Read schemaVersion; we never remove a field without one.

Every delivered body carries schemaVersion, the payload generation the event was written in — not the generation current at delivery time. A redelivery of an old event reports the old version, on purpose: replaying history should not re-label it.

A version bump means the shape or meaning of a payload changed. An additive optional field does not bump the version, so write your handler to ignore fields it does not recognise.

transaction.* is at version 2. Version 1 was inconsistent about the commitment key: state transitions and transaction.escrowed sent transactionId; transaction.disputed and transaction.dispute_resolved sent bookingId; transaction.funds_released and transaction.outcome_recorded sent both. Version 2 sends both keys on every transaction event, always the same value.

Nothing was removed, so nothing breaks. If your integration reads bookingId, it keeps working. If it reads transactionId, it keeps working. Migrating is optional and consists of deleting code: you can stop branching on the event type to find the commitment, and read whichever key you prefer on all seven event types.

// v1 — you had to know which event you were holding
const id = body.payload.transactionId ?? body.payload.bookingId;

// v2 — either key, every transaction event, same value
const id = body.payload.bookingId;

Version 1 payloads remain valid forever for historical events, so a replay of anything published before the cutover still validates against the shape it was written in. Do not assume both keys are present when schemaVersion is 1.

Delivery semantics

At-least-once, from a transactional outbox.

Every event is written to a durable outbox inside the same database transaction as the state change that produced it — an event exists if and only if the change committed. A dispatcher then delivers off the request path, so a slow endpoint on your side never slows an OpenDoc transaction.

  • At-least-once. Retries (and operator redeliveries) can deliver the same event more than once. Dedupe by x-opendoc-event-id (also eventId in the body) — it is stable across retries and redeliveries.
  • Retry schedule. A delivery that fails with a network error, timeout (8s), HTTP 5xx, 408, or 429 is retried after 30s, 2m, 10m, 1h, 6h, and 24h (7 total attempts), then abandoned with an audit record. Other 4xx responses are treated as permanent for that subscription and are not retried. x-opendoc-attempt carries the attempt number.
  • Respond fast with 2xx. Return 200 as soon as you have persisted the event; do your processing asynchronously. Anything except a 2xx counts as a failed delivery.
  • Ordering is not guaranteed. Retries can interleave events. Use the state in the payload (or re-read the resource) rather than assuming arrival order.
  • Auto-pause. After 10 consecutive failed deliveries the subscription is paused so we stop hammering a dead endpoint (this is audited on our side). Fix your endpoint, then call POST /event-subscriptions/:id/resume; use the replay endpoint below to recover anything missed while paused.

Verification

Verify the timestamped signature.

OpenDoc signs each delivery with HMAC-SHA256 over `${t}.${rawBody}` and sends it as x-opendoc-signature: t=<unix_seconds>,v1=<hex>. Verify the HMAC and reject stale timestamps (recommended tolerance 300s) so a captured delivery cannot be replayed. Use the one-time signing secret from subscription creation. @opendoc/sdk's verifyWebhookSignature implements exactly this.

import crypto from "node:crypto";

function verifyOpenDocWebhook(rawBody, signatureHeader, signingSecret) {
  // signatureHeader: "t=1754400000,v1=5f3a..."
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((kv) => kv.split("=", 2))
  );
  if (!parts.t || !parts.v1) return false;

  const toleranceSeconds = 300;
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSeconds) {
    return false; // stale — possible replay
  }

  const expected = crypto
    .createHmac("sha256", signingSecret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");

  return crypto.timingSafeEqual(
    Buffer.from(parts.v1, "hex"),
    Buffer.from(expected, "hex")
  );
}
Headers on every delivery: x-opendoc-event (type), x-opendoc-event-id (dedupe key), x-opendoc-correlation-id, x-opendoc-attempt, x-opendoc-signature, and — during the deprecation window — x-opendoc-signature-legacy.
Deprecation: the previous unsalted signature (bare hex HMAC of the body alone) is still sent in x-opendoc-signature-legacy for one deprecation cycle. It has no timestamp and is replayable — migrate verification to the timestamped scheme.

Introspection and replay

Inspect deliveries and redeliver an event.

Every delivery attempt is recorded with its outcome, HTTP status, and attempt number. If your endpoint lost an event (bug, outage, auto-pause), fetch the log and re-enqueue the exact event for your subscription — the redelivered event keeps its original eventId, so your dedupe logic still works.

# paginated attempt log (limit / offset)
curl "https://api.opendoc.com/event-subscriptions/$SUB_ID/deliveries?limit=25" \
  -H "Authorization: Bearer $OPENDOC_AGENT_TOKEN"

# re-enqueue one event for this subscription (202 Accepted)
curl -X POST \
  "https://api.opendoc.com/event-subscriptions/$SUB_ID/deliveries/$DELIVERY_ID/redeliver" \
  -H "Authorization: Bearer $OPENDOC_AGENT_TOKEN"