Webhook Publisher
Webhook Publisher
Section titled “Webhook Publisher”Webhook Publisher (Koa) receives webhooks from Strapi and routes events to RabbitMQ exchanges for async processing.
Key Facts
Section titled “Key Facts”- Port: 1347
- Tech: Koa, JavaScript (no TypeScript)
- Role: Event bridge (Strapi → RabbitMQ)
- Messaging: AMQP to RabbitMQ
Purpose
Section titled “Purpose”Strapi fires webhooks on lifecycle events (create, update, delete). Webhook Publisher:
- Receives webhook from Strapi (or another internal caller)
- Extracts event type (user.create, organisation.update, etc.)
- Publishes to appropriate RabbitMQ exchange
- Returns 200 immediately (fire-and-forget), or 202 if the message had to be queued in-memory
- RabbitMQ handles message delivery to consumers
Event Flow
Section titled “Event Flow”Strapi (lifecycle hook) / internal caller ↓POST http://webhook-publisher:1347/webhook-publisherContent-Type: application/jsonX-Webhook-Signature: <hmac-sha256-hex>{ "event": "user.create", "data": { ... }} ↓Webhook Publisher ├─ Verify HMAC signature (fail-closed) ├─ Map event type to exchange + routing key └─ Publish to RabbitMQ (or queue in-memory if unreachable) ↓RabbitMQ ├─ user exchange (direct, routing key: create/update/delete) ├─ organisation exchange (direct, routing key: create/update/delete) ├─ cloud.events (topic, routing key: full event name) ├─ git.events (topic, routing key: full event name) └─ notification.events (topic, routing key: full event name) ↓Consumers subscribed to exchange/routing key ├─ auth-service-consumer (user, organisation exchanges) ├─ git-connector (git.events) ├─ cloud-connector (cloud.events) └─ notification-service (notification.events)Authentication
Section titled “Authentication”POST /webhook-publisher requires a mandatory X-Webhook-Signature header. The
service verifies an HMAC-SHA256 signature computed over the raw request body
using the shared secret WEBHOOK_PUBLISHER_SECRET, compared with
crypto.timingSafeEqual (no timing side-channel).
The endpoint fails closed — it rejects with 401 if any of the following is true:
WEBHOOK_PUBLISHER_SECRETis not configured in the environment (the service never falls back to accepting unsigned requests),- the
X-Webhook-Signatureheader is missing, - the computed signature does not match the header value.
The header value may optionally carry a sha256=<hex> prefix (a common webhook
signature convention) — the prefix is stripped before comparison if present.
Callers sign the request like this:
const crypto = require('crypto');
const signature = crypto .createHmac('sha256', process.env.WEBHOOK_PUBLISHER_SECRET) .update(rawBodyString) // must be the exact string sent as the request body .digest('hex');
// header sent with the request:// X-Webhook-Signature: <signature>All internal callers (registration controller, lifecycle hooks, cloud-instance
and related lifecycle hooks, SCIM controller, etc.) must sign their requests
with the same WEBHOOK_PUBLISHER_SECRET.
Webhook Endpoint
Section titled “Webhook Endpoint”POST http://webhook-publisher:1347/webhook-publisherContent-Type: application/jsonX-Webhook-Signature: <hmac-sha256-hex-of-raw-body>
{ "event": "user.create", "data": { "id": 1, "email": "user@example.com", "role": { "name": "provisioned" } }}Response (published immediately):
{ "success": true}Response (queued in-memory because RabbitMQ is unreachable):
{ "success": true, "queued": true, "message": "Message accepted and queued for later delivery"}Event Mapping
Section titled “Event Mapping”Webhook Publisher determines the target exchange and routing key from the
event string. Routing logic lives in index.js at the repo root (there is no
src/routes/webhook.js file).
| Event pattern | Exchange | Type | Routing key | Condition |
|---|---|---|---|---|
organisation.* | organisation | direct | suffix after the dot (create/update/…) | always |
registration.create | user | direct | create | always — transformed into a user.create payload before publishing |
user.create | user | direct | create | only if data.role.name === 'provisioned' |
user.update | user | direct | update | only if data.role.name === 'provisioned', or if the update carries emailVerified, confirmed, or blocked (admin update) |
cloud-instance.*, cloud-resource.*, inventory.*, cloud-network.*, cloud-subnet.*, cloud-sg.*, cloud-vpn.*, cloud-nat.*, cloud-route-table.*, backup-policy.* | cloud.events | topic | full event name | always |
git-repository.* | git.events | topic | full event name | always |
organisation.invitation.*, dpa.*, sponsorship.*, onboarding.*, feedback.* | notification.events | topic | full event name | always |
Events that match none of the above are rejected with 400 Unsupported event type.
user.* events that don’t meet the provisioned/admin-update condition are
accepted with 200 but explicitly filtered out (not published).
Topic vs. direct routing key: for topic exchanges (cloud.events,
git.events, notification.events) the routing key is the full event
name (e.g. cloud-instance.provision), so consumer queues can bind with
wildcard patterns. For direct exchanges (user, organisation) the routing
key is just the suffix after the first dot.
registration.create → user.create transformation
Section titled “registration.create → user.create transformation”registration.create is a special case: it is not routed as-is. The payload is
rewritten into a user.create event and published to the user exchange with
routing key create:
// Incoming{ event: 'registration.create', data: { username, email, password, firstName, enabled, emailVerified } }
// Transformed and published{ event: 'user.create', data: { username, email, password, enabled, emailVerified, userInfo: { name, enabled, emailVerified }, requiredActions: ['VERIFY_EMAIL'], },}RabbitMQ Exchanges
Section titled “RabbitMQ Exchanges”| Exchange | Type | Subscribers | Purpose |
|---|---|---|---|
user | direct | auth-service-consumer | User sync (KC ↔ Strapi) |
organisation | direct | auth-service-consumer | Organisation changes |
cloud.events | topic | cloud-connector | Cloud provisioning / inventory / networking / backup policy |
git.events | topic | git-connector | Git repository operations |
notification.events | topic | notification-service | Invitations, DPA, sponsorship, onboarding, feedback emails/alerts |
platform.events | fanout | event-store-consumer | Unified event log mirror of every successfully published message |
Degraded Mode (no RabbitMQ-side fallback queues)
Section titled “Degraded Mode (no RabbitMQ-side fallback queues)”Webhook Publisher does not rely on RabbitMQ-side dead-letter/fallback
queues. If RabbitMQ is unreachable at startup or the connection drops, the
service keeps running (unless RABBITMQ_REQUIRED=true) and queues outgoing
messages in-memory, in the Node process itself:
- Connection attempts retry up to
RABBITMQ_RETRY_ATTEMPTStimes (default5), waitingRABBITMQ_RETRY_DELAYms (default5000) with exponential backoff between attempts. - While disconnected, every message that would have been published is pushed
onto an in-memory queue instead, and the HTTP response is
202 Acceptedwithqueued: true. - On successful reconnect, the in-memory queue is flushed back to RabbitMQ in order.
RABBITMQ_REQUIRED(defaultfalse) controls whether a fully exhausted retry budget is fatal:falselets the service boot and serve requests in degraded mode even if RabbitMQ was never reachable;truemakes startup fail instead.
This means an in-memory queue is lost if the process restarts before reconnecting — there is no on-disk or RabbitMQ-side persistence for not-yet-published messages.
Configuration
Section titled “Configuration”.env variables:
# RabbitMQ connectionRABBITMQ_HOST=rabbitmqRABBITMQ_PORT=5672RABBITMQ_USER=guestRABBITMQ_PASS=guest
# Reconnect behavior (degraded mode)RABBITMQ_RETRY_ATTEMPTS=5RABBITMQ_RETRY_DELAY=5000RABBITMQ_REQUIRED=false
# Server portPORT=1347
# HMAC-SHA256 shared secret for POST /webhook-publisher (fail-closed if unset)WEBHOOK_PUBLISHER_SECRET=Endpoints
Section titled “Endpoints”Health Check
Section titled “Health Check”GET /health
Response:{ "status": "ok" | "degraded" | "error", "statusMessage": "...", "timestamp": "2024-01-01T12:00:00Z", "rabbitmq": { "connected": true, "host": "rabbitmq", "port": "5672", "queuedMessages": 0 }}status is degraded (HTTP 200) when RabbitMQ is unreachable but
RABBITMQ_REQUIRED is false, and error (HTTP 500) when RabbitMQ is
unreachable and RABBITMQ_REQUIRED is true.
Webhook (from Strapi / internal callers)
Section titled “Webhook (from Strapi / internal callers)”POST /webhook-publisherX-Webhook-Signature: <hmac-sha256-hex-of-raw-body>
Request:{ "event": "user.create", "data": { ... }}
Response:{ "success": true}# View logsdocker logs -f webhook-publisher
# Example output:2024-01-01T12:00:00Z [INFO] Received webhook request: {"event":"user.create",...}2024-01-01T12:00:00Z [INFO] Event "user.create" mapped to "user" exchange2024-01-01T12:00:01Z [INFO] Successfully published message to exchange "user" with routing key "create"Troubleshooting
Section titled “Troubleshooting””AMQP connection refused”
Section titled “”AMQP connection refused””A: RabbitMQ not running or not accessible:
docker logs sencai-mq# Ensure sencai-mq profile is running: ./run-local.sh mqThe service will keep serving requests in degraded mode (queuing messages
in-memory) unless RABBITMQ_REQUIRED=true.
401 “Missing or invalid webhook signature”
Section titled “401 “Missing or invalid webhook signature””A: Check:
WEBHOOK_PUBLISHER_SECRETis set and identical between webhook-publisher and the caller- The caller signs the exact raw body bytes sent in the request (not a re-serialized object)
- The header name is
X-Webhook-Signature(case-insensitive)
Webhook events not reaching consumers
Section titled “Webhook events not reaching consumers”A: Check:
- Webhook Publisher logs:
docker logs webhook-publisher - RabbitMQ UI (localhost:15672): Check exchanges and queues
- Consumer logs:
docker logs auth-service-consumer
/health shows queuedMessages > 0
Section titled “/health shows queuedMessages > 0”A: RabbitMQ was unreachable at some point; messages are sitting in the in-memory queue and will flush automatically on reconnect. If the process restarts before reconnecting, those queued messages are lost.
”Unsupported event type” (400)
Section titled “”Unsupported event type” (400)”A: The event string doesn’t match any of the recognized prefixes in
index.js (organisation.*, registration.create, user.*,
cloud-instance.*/cloud-resource.*/inventory.*/cloud-network.*/
cloud-subnet.*/cloud-sg.*/cloud-vpn.*/cloud-nat.*/
cloud-route-table.*/backup-policy.*, git-repository.*,
organisation.invitation.*/dpa.*/sponsorship.*/onboarding.*/feedback.*).
Add a new branch to the routing logic in index.js if a new event prefix is needed.