The widget runs in the browser, so it can’t directly observe events that happen after a /messages response goes out — when a queue worker finishes async work, when a waiting_time flow transitions to completed, when the conversation is aborted. Webhooks are how Comerix Flow tells your backend about those events, so your backend can react however it likes: push to the widget via your own channel (WebSocket, Pusher, Ably, Server-Sent Events from your server), update your CRM, send an email, fan out to a queue, anything.
Register a webhook URL
A webhook is bound to a widget key. Pass --webhook=<url> at creation time and the server returns the shared HMAC secret in plaintext once — save it, it isn’t shown again.
ddev console comerix:widget-key:create \
--tenant=<tenantId> --label='acme.com chat' \
--origin='https://acme.com' \
--intent=order_status --intent=returns \
--webhook='https://acme.com/webhooks/comerix-flow'
Output:
Public key : pk_live_…
Webhook URL : https://acme.com/webhooks/comerix-flow
Webhook secret : whsec_d2f9…ae51
Stash the secret in your secrets manager. Every delivery is signed with HMAC-SHA256 using it.
If you omit --webhook, no events are sent. You can add or rotate a webhook later by issuing a new key (current V1 — admin UI / patch endpoint is on the roadmap).
Anatomy of a delivery
When something happens, the server enqueues a row in fs_webhook_deliveries and the comerix:webhooks:deliver worker (cron, every minute) POSTs it:
POST https://acme.com/webhooks/comerix-flow
Content-Type: application/json
User-Agent: Comerix-Flow-Webhook/1.0
X-Comerix-Event: chat.execution.updated
X-Comerix-Event-Id: 01935-aaaa-bbbb-cccc-dddddddddddd
X-Comerix-Delivery: 01935-eeee-ffff-1111-222222222222
X-Comerix-Signature: sha256=df4e526e6c94e8a5552d1a055d95f325b83db013bb3b44e926aab8e256993d22
{
"event": "chat.execution.updated",
"widget_key_id": "01935-...",
"occurred_at": "2026-05-15T12:30:00+00:00",
"tenant_id": "019e2667-...",
"conversation_id": "0193f8a1-...",
"execution_id": "01935-aaaa-...",
"mode": "resume",
"status": "completed",
"blocks": [ ... ],
"wait_token": null,
"token_usage": {
"prompt_tokens": 412,
"completion_tokens": 87,
"total_tokens": 499,
"model": "gpt-4o-mini",
"cost_micros": 318,
"cost_usd": 0.000318
}
}
Headers explained:
| Header | Why |
|---|
X-Comerix-Event | Event type — same as payload.event. Useful for routing without parsing the body. |
X-Comerix-Event-Id | UUID of the engine event. Stable across retries of the same event. Use for idempotency. |
X-Comerix-Delivery | UUID of this specific attempt. Different across retries. |
X-Comerix-Signature | sha256=<hmac> over the raw request body. Verify before trusting the payload. |
Verify the signature
Compute the same HMAC over the raw request body and compare with the header value. Use a constant-time comparison.
PHP
$body = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $body, $WEBHOOK_SECRET);
$received = $_SERVER['HTTP_X_COMERIX_SIGNATURE'] ?? '';
if (!hash_equals($expected, $received)) {
http_response_code(401);
exit;
}
$payload = json_decode($body, associative: true);
Node.js
import crypto from 'node:crypto';
app.post('/webhooks/comerix-flow', express.raw({ type: 'application/json' }), (req, res) => {
const expected = 'sha256=' + crypto.createHmac('sha256', WEBHOOK_SECRET)
.update(req.body).digest('hex');
const received = req.header('X-Comerix-Signature') || '';
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))) {
return res.status(401).end();
}
const payload = JSON.parse(req.body.toString('utf8'));
// …react…
res.sendStatus(204);
});
Python (FastAPI)
import hmac, hashlib
from fastapi import FastAPI, Request, HTTPException
@app.post("/webhooks/comerix-flow")
async def receive(req: Request):
body = await req.body()
expected = "sha256=" + hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, req.headers.get("X-Comerix-Signature", "")):
raise HTTPException(status_code=401)
payload = await req.json()
# …react…
return {"ok": True}
Event catalog
| Event | When | Payload (in addition to envelope fields) |
|---|
chat.session.opened | POST /sessions succeeded | conversation_id, customer_id, locale |
chat.execution.updated | After POST /messages (trigger or resume) returned a Reply | execution_id, mode (trigger/resume), status, blocks, wait_token, token_usage |
token_usage on chat.execution.updated
When the turn that produced this reply called an LLM, the payload carries a token_usage object summing the prompt/completion tokens and the estimated cost across the turn. It mirrors the token_usage returned inline on the /messages reply, so you can record spend from your backend without parsing the widget response.
token_usage is null when no LLM call accrued for the turn (a static reply, a non-LLM branch, or a flow that paused on async work before any model ran). Always null-check before reading the fields.
| Field | Type | Meaning |
|---|
prompt_tokens | integer | Tokens billed for the prompt across the turn. |
completion_tokens | integer | Tokens billed for the completion across the turn. |
total_tokens | integer | Sum of prompt_tokens and completion_tokens. |
model | string | null | The model id when the whole turn used exactly one model; null if the turn mixed models. |
cost_micros | integer | Estimated cost in micro-USD (1e-6 USD). Use this for summing — integers add without floating-point drift. |
cost_usd | number | The same estimate in whole US dollars (cost_micros / 1_000_000). Convenience only. |
"token_usage": {
"prompt_tokens": 412,
"completion_tokens": 87,
"total_tokens": 499,
"model": "gpt-4o-mini",
"cost_micros": 318,
"cost_usd": 0.000318
}
Sum cost_micros (an integer) across deliveries for accurate spend, and divide by 1,000,000 once at the end. Don’t accumulate cost_usd — the floats drift.
Envelope fields present on every event:
{
"event": "<event-type>",
"widget_key_id": "<uuid>",
"occurred_at": "<iso8601>",
"tenant_id": "<uuid>",
"conversation_id": "<uuid>"
}
More events are on the roadmap: chat.session.closed, chat.execution.aborted, chat.message.replied (separate from the umbrella updated). The catalog is conservative on purpose — adding events is non-breaking, removing them is breaking, so we add slowly.
Idempotency and ordering
- Idempotent at the engine level: the same engine event won’t enqueue twice for the same widget key.
(widget_key_id, event_id) is a unique constraint on the outbox.
- Idempotent at the receiver level: retries reuse
X-Comerix-Event-Id. Dedupe on it (write-once with a processed_events table is the usual pattern).
- Order is best-effort, not guaranteed. If two events for the same conversation are enqueued in close succession and one retry takes longer, the second may land before the retry of the first. If order matters, use
payload.occurred_at to reconstruct.
Retry behaviour
The worker tries each delivery according to this schedule:
| Attempt | Wait before this attempt |
|---|
| 1 | 0 (immediate on next cron tick) |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 15 minutes |
| 5 | 1 hour |
| 6 | 4 hours |
| — | After 6 attempts the row is marked dead. No more retries. |
What counts as retryable:
- Network errors (DNS, connection refused, timeout)
- HTTP 408 (Request Timeout)
- HTTP 425 (Too Early)
- HTTP 429 (Too Many Requests)
- HTTP 5xx
What stops retries immediately (permanent failure):
- HTTP 4xx other than 408/425/429 — your endpoint rejected the request, retrying won’t help. Fix the endpoint and replay manually.
Receiver checklist
- Respond with a 2xx within 10 seconds. If you need to do real work, ack first (enqueue internally) and process async.
- Verify the signature before trusting any field in the body.
- Dedupe by
X-Comerix-Event-Id.
- Return 4xx only when the request is structurally wrong (bad signature, malformed JSON). Return 5xx when your service is the problem so the worker retries.
- Log
X-Comerix-Delivery alongside your own ids — it lets us correlate “you didn’t get this” reports.
Operating the outbox
The outbox lives in fs_webhook_deliveries. Useful queries:
-- Deliveries that didn't make it
SELECT id, event_type, attempts, last_error, last_response_status
FROM fs_webhook_deliveries
WHERE dead_at IS NOT NULL
ORDER BY dead_at DESC
LIMIT 20;
-- Pending right now
SELECT id, event_type, attempts, next_attempt_at
FROM fs_webhook_deliveries
WHERE succeeded_at IS NULL AND dead_at IS NULL
ORDER BY next_attempt_at;
Manual retry of a dead row: set dead_at = NULL, attempts = 0, next_attempt_at = NOW(). The next worker tick will pick it up.
Cron wiring
Run the worker every minute:
* * * * * cd /var/www/html && bin/console comerix:webhooks:deliver --limit=100 >> var/log/webhooks.log 2>&1
If you’re on DDEV in development, the Scheduler module can run it as a registered cron job — pick whichever path matches your deployment.