Skip to main content

Platform event webhooks

A tenant can subscribe one or more URLs to platform events: every activity, delivery status changes, conversation lifecycle, channel health and circuit breaker transitions, and participant opt-outs. Converger POSTs each event as signed JSON, retries failures under the shared retry policy, and keeps a 7-day event log with a per-subscription dead-letter queue and a "redeliver" action.

This is different from a webhook channel: a channel is a delivery target for the activities of the conversations routed to it, while a subscription is a tenant-wide feed of what happens on the platform. Use a subscription to mirror activities into your CRM, alert on delivery.failed or channel.circuit_opened, or close a ticket on conversation.closed, without creating a channel per conversation.

The decision record is ADR-0053.

Events travel through the event backbone (ADR-0070): the code that emits an event publishes it, and the built-in webhooks backbone subscription writes the event log rows and enqueues their deliveries, in the same transaction as the event's idempotency receipt. Whatever the backbone (Native, Kafka, NATS JetStream, RabbitMQ), a subscription gets each event once, and the event's id (also the x-converger-event-id header) is the backbone event id. With a broker backbone the same events are on the broker's topics for your own consumers.

Subscriptions​

A subscription has:

FieldNotes
urlhttp or https. Private, loopback and link-local targets are rejected, like webhook channels (SSRF guard).
eventsEvent types from the catalog, or "*" for every type. At least one.
secretSigning secret (whsec_...). Generated when not given, at least 16 characters. Returned only when the subscription is created or its secret rotated. Encrypted at rest.
enabledfalse stops new events; queued events of a disabled subscription are dead-lettered.
headersExtra request headers (string values), for example authorization. Encrypted at rest; the API returns only their names. host, content-length, content-type, hop-by-hop headers and x-converger-* are reserved.
descriptionFree text.
managed_byRead-only. alert_webhook_url for the subscription that mirrors the tenant's legacy alert URL (below).

Manage subscriptions with the tenant API, in the tenant portal (Webhooks, owners and admins; members and viewers can look) or in the admin panel (Webhooks, super admins and admins). Both UIs can also rotate a subscription's signing secret (shown once) and page through the event log. Every change is audit-logged (resource_type: "webhook_subscription"). The endpoints are in the OpenAPI document (tag Webhooks) and answer errors with the error envelope, for example 422 validation_failed with the invalid fields in details.

Event catalog​

Every type below is emitted today. Events are produced from the same hooks that already broadcast these changes to WebSocket clients, after the change is committed.

TypeWhendata
activity.createdEvery new activity, from any channel, the API or the system (including conversationUpdate lifecycle activities)The canonical activity: id, type, sender, text, attachments, metadata, idempotency_key, seq, reply_to_id, edited_at, deleted_at, conversation_id, tenant_id, inserted_at
delivery.sentA delivery left Converger (the adapter accepted it, or a client acknowledged it)Delivery data
delivery.deliveredThe provider reported the message deliveredDelivery data
delivery.readThe provider reported the message readDelivery data
delivery.failedA delivery was dead-lettered (retries exhausted, permanent error, middleware halt) or the provider reported it failedDelivery data
conversation.createdA conversation was created (API, client API or inbound resolution)Conversation data
conversation.closedA conversation was closed by hand or by the inactivity expiryConversation data plus reason (manual, expired, ...)
conversation.reopenedA closed conversation was reopenedConversation data plus reason
channel.health_changedThe health check (every 5 minutes) found a different status than the previous onechannel_id, channel_name, channel_type, tenant_id, previous_status, status, failure_rate, total_deliveries, failed_deliveries, checked_at
channel.circuit_openedThe channel's delivery circuit breaker opened (health unhealthy, consecutive failures, or a failed half-open probe)channel_id, channel_name, channel_type, tenant_id, circuit_state: "open", reason, changed_at
channel.circuit_closedA probe succeeded and the breaker closedSame as channel.circuit_opened, circuit_state: "closed"
participant.opted_outA participant opted out (for example an SMS STOP); opting out again sends nothingparticipant_id, channel_id, external_id, display_name, opted_out_at

A manual pause or resume of a channel is not an event. GET /api/v1/webhook_event_types returns the catalog.

Delivery data​

{
"delivery_id": "1c2d3e4f-...", "activity_id": "9b2f6c1e-...", "conversation_id": "3a4b5c6d-...",
"channel_id": "0d2f4b6a-...", "status": "failed", "attempts": 5,
"last_error": "webhook returned 503: ...", "provider_message_id": null,
"sent_at": null, "delivered_at": null, "read_at": null, "updated_at": "2026-10-12T10:00:00.000000Z"
}

Conversation data​

{
"conversation_id": "3a4b5c6d-...", "status": "closed", "channel_id": "0d2f4b6a-...",
"origin_channel_id": "0d2f4b6a-...", "participant_id": null, "metadata": {},
"inserted_at": "2026-10-12T09:00:00Z", "updated_at": "2026-10-12T10:00:00.000000Z", "reason": "manual"
}

Requests​

POST /converger-events HTTP/1.1
content-type: application/json
user-agent: Converger-Webhooks/1
x-converger-event: delivery.failed
x-converger-event-id: 5f0c1d2e-0000-4000-8000-00000000e001
x-converger-delivery-id: 8e7d6c5b-...
x-converger-signature: t=1760263200,v1=872ae23f...

{"id":"5f0c1d2e-...","type":"delivery.failed","created_at":"2026-10-12T10:00:00.000000Z","tenant_id":"7a1b2c3d-...","data":{...}}
HeaderMeaning
x-converger-eventThe event type.
x-converger-event-idThe occurrence (id in the body). The same for every subscription that receives it.
x-converger-delivery-idThis subscription's copy of the event (the event log row). Stable across retries and redeliveries: use it to deduplicate.
x-converger-signaturet=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>.

The body is the envelope {id, type, created_at, tenant_id, data}. Answer with any 2xx within the receive timeout (10 s by default); the response body is ignored (at most 64 KB is read). Redirects are not followed: a 3xx is a failure. Events can arrive out of order and more than once (a timeout after the receiver processed the request is retried), so make handlers idempotent on x-converger-delivery-id and order by the timestamps in data when it matters.

Verifying the signature​

The scheme is the one used by webhook channels and inbound requests, so the same code verifies all of them: split the header, check that t is within your tolerance (300 s recommended), compute HMAC-SHA256(secret, "<t>.<raw body>") over the raw request body and compare it in constant time with v1.

Test vector​

secret: whsec_test_secret
t: 1760263200
body: {"id":"5f0c1d2e-0000-4000-8000-00000000e001","type":"delivery.failed","created_at":"2026-10-12T10:00:00.000000Z","tenant_id":"7a1b2c3d-0000-4000-8000-0000000000aa","data":{"delivery_id":"1c2d3e4f-0000-4000-8000-0000000000d1","status":"failed"}}
header: t=1760263200,v1=872ae23fc46fdd161ee57bef17465a858512ed24dd3e70d131d2ea2729f4c206

Node.js​

const crypto = require("crypto");

function verify(secret, header, rawBody, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=", 2)));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const given = Buffer.from(parts.v1 || "", "utf8");
return given.length === expected.length && crypto.timingSafeEqual(given, Buffer.from(expected));
}

Python​

import hashlib, hmac, time

def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
parts = dict(p.strip().split("=", 1) for p in header.split(","))
t = int(parts.get("t", "0"))
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))

Elixir​

def verify(secret, header, raw_body, tolerance \\ 300) do
parts = header |> String.split(",") |> Map.new(&(&1 |> String.trim() |> String.split("=", parts: 2) |> List.to_tuple()))
t = String.to_integer(parts["t"])
expected = :crypto.mac(:hmac, :sha256, secret, "#{t}.#{raw_body}") |> Base.encode16(case: :lower)

abs(System.system_time(:second) - t) <= tolerance and
Plug.Crypto.secure_compare(expected, parts["v1"] || "")
end

Rotating the secret (POST /api/v1/webhook_subscriptions/:id/rotate_secret) takes effect for the next attempt, including retries of events already queued; accept both secrets for a while if you rotate without downtime.

Retries and dead letters​

Each event is delivered by an Oban job (Converger.Workers.WebhookEventWorker, queue webhooks) under the shared retry policy (config :converger, :retry_policy, see delivery retries): by default 5 attempts with exponential backoff (base_ms * 3^attempt, capped at one hour). A receiver's Retry-After overrides the backoff for the next attempt.

OutcomeResult
2xxdelivered
408, 425, 429, 5xx, timeout, connection error, DNS failureRetried; pending with attempts, last_error and response_status
Other 4xx, 3xx, blocked target (SSRF guard)Dead-lettered at once (failed)
Last allowed attempt failsDead-lettered (failed)
The subscription was disabled or deleted meanwhileDead-lettered (failed, subscription disabled), or removed with the subscription

A dead-lettered event stays in the log with its last error and HTTP status. Redeliver it from the portal or admin page, or with POST /api/v1/webhook_events/:id/redeliver: it goes back to pending with attempts reset and a new job, keeps its x-converger-delivery-id, and the action is audit-logged (action: "redeliver"). Only failed events can be redelivered; any other status is 409 with the code not_failed in the error envelope.

Telemetry: [:converger, :webhooks, :emitted] (count), [:converger, :webhooks, :delivered], [:converger, :webhooks, :failed] (a retried attempt) and [:converger, :webhooks, :dead_lettered], with tenant_id, subscription_id and type metadata.

Retention​

The event log (webhook_events) keeps 7 days. The daily PruneWorker (01:30 UTC) deletes older rows in batches; change the window with WEBHOOK_EVENT_RETENTION_DAYS (0 keeps events forever). See retention.

The legacy alert_webhook_url​

tenants.alert_webhook_url used to receive unsigned, fire-and-forget health and circuit breaker alerts. It is now a subscription with managed_by: "alert_webhook_url" on channel.health_changed, channel.circuit_opened and channel.circuit_closed:

  • the migration created one for every tenant that had the URL set;
  • setting, changing or clearing the URL in the admin tenant form keeps that subscription in sync;
  • its requests keep the legacy body ({"event": "channel_health_changed", "new_status": ..., ...} and {"event": "channel.circuit_opened", "reason": ..., "changed_at": ...}, see channel health), so existing receivers keep working, but they are now signed (with a generated secret, which an admin can rotate through the API), retried and logged;
  • like before, a breaker re-opened by a failed probe is not sent to it.

Configuration​

SettingDefaultNotes
config :converger, :platform_webhooks, enabled:truefalse stops recording and sending webhook events (subscriptions are kept; events are still published to the backbone).
config :converger, :platform_webhooks, connect_timeout:5000Connect timeout, ms.
config :converger, :platform_webhooks, receive_timeout:10000Response timeout, ms.
config :converger, :platform_webhooks, max_response_bytes:65536Response bytes read.
config :converger, :retry_policy5 attempts, exponential, 10 s baseShared with channel deliveries.
WEBHOOK_EVENT_RETENTION_DAYS7Event log retention (Converger.Retention, webhook_event_days).
WEBHOOK_ALLOWED_TARGETS, WEBHOOK_ALLOW_PRIVATE_TARGETSunsetSSRF guard escape hatches, shared with webhook channels.
Oban queue webhooks10Concurrency of event delivery.