Skip to content

Webhook Publisher

Webhook Publisher (Koa) receives webhooks from Strapi and routes events to RabbitMQ exchanges for async processing.

  • Port: 1347
  • Tech: Koa, JavaScript (no TypeScript)
  • Role: Event bridge (Strapi → RabbitMQ)
  • Messaging: AMQP to RabbitMQ

Strapi fires webhooks on lifecycle events (create, update, delete). Webhook Publisher:

  1. Receives webhook from Strapi (or another internal caller)
  2. Extracts event type (user.create, organisation.update, etc.)
  3. Publishes to appropriate RabbitMQ exchange
  4. Returns 200 immediately (fire-and-forget), or 202 if the message had to be queued in-memory
  5. RabbitMQ handles message delivery to consumers
Strapi (lifecycle hook) / internal caller
POST http://webhook-publisher:1347/webhook-publisher
Content-Type: application/json
X-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)

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_SECRET is not configured in the environment (the service never falls back to accepting unsigned requests),
  • the X-Webhook-Signature header 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.

Terminal window
POST http://webhook-publisher:1347/webhook-publisher
Content-Type: application/json
X-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"
}

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 patternExchangeTypeRouting keyCondition
organisation.*organisationdirectsuffix after the dot (create/update/…)always
registration.createuserdirectcreatealways — transformed into a user.create payload before publishing
user.createuserdirectcreateonly if data.role.name === 'provisioned'
user.updateuserdirectupdateonly 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.eventstopicfull event namealways
git-repository.*git.eventstopicfull event namealways
organisation.invitation.*, dpa.*, sponsorship.*, onboarding.*, feedback.*notification.eventstopicfull event namealways

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.createuser.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'],
},
}
ExchangeTypeSubscribersPurpose
userdirectauth-service-consumerUser sync (KC ↔ Strapi)
organisationdirectauth-service-consumerOrganisation changes
cloud.eventstopiccloud-connectorCloud provisioning / inventory / networking / backup policy
git.eventstopicgit-connectorGit repository operations
notification.eventstopicnotification-serviceInvitations, DPA, sponsorship, onboarding, feedback emails/alerts
platform.eventsfanoutevent-store-consumerUnified 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_ATTEMPTS times (default 5), waiting RABBITMQ_RETRY_DELAY ms (default 5000) 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 Accepted with queued: true.
  • On successful reconnect, the in-memory queue is flushed back to RabbitMQ in order.
  • RABBITMQ_REQUIRED (default false) controls whether a fully exhausted retry budget is fatal: false lets the service boot and serve requests in degraded mode even if RabbitMQ was never reachable; true makes 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.

.env variables:

Terminal window
# RabbitMQ connection
RABBITMQ_HOST=rabbitmq
RABBITMQ_PORT=5672
RABBITMQ_USER=guest
RABBITMQ_PASS=guest
# Reconnect behavior (degraded mode)
RABBITMQ_RETRY_ATTEMPTS=5
RABBITMQ_RETRY_DELAY=5000
RABBITMQ_REQUIRED=false
# Server port
PORT=1347
# HMAC-SHA256 shared secret for POST /webhook-publisher (fail-closed if unset)
WEBHOOK_PUBLISHER_SECRET=
Terminal window
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.

Terminal window
POST /webhook-publisher
X-Webhook-Signature: <hmac-sha256-hex-of-raw-body>
Request:
{
"event": "user.create",
"data": { ... }
}
Response:
{
"success": true
}
Terminal window
# View logs
docker 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" exchange
2024-01-01T12:00:01Z [INFO] Successfully published message to exchange "user" with routing key "create"

A: RabbitMQ not running or not accessible:

Terminal window
docker logs sencai-mq
# Ensure sencai-mq profile is running: ./run-local.sh mq

The 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:

  1. WEBHOOK_PUBLISHER_SECRET is set and identical between webhook-publisher and the caller
  2. The caller signs the exact raw body bytes sent in the request (not a re-serialized object)
  3. The header name is X-Webhook-Signature (case-insensitive)

A: Check:

  1. Webhook Publisher logs: docker logs webhook-publisher
  2. RabbitMQ UI (localhost:15672): Check exchanges and queues
  3. Consumer logs: docker logs auth-service-consumer

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.

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.