I exported the production n8n instance to check the CrowdRelay integration. There were 49 workflows in total and 19 with CrowdRelay in the name.

That sounded reassuring for roughly ten seconds.

Nineteen rooms, no front door

The CrowdRelay workflows were branch handlers triggered by Execute Workflow. They knew how to react to events such as a paid ticket order, an issued admission pass or a reminder becoming due. What they did not have was a public entry point. No webhook. No verified dispatcher. No place where an event from the Rust worker could actually enter the system.

I had built the rooms and forgotten the front door.

A webhook is not verification

Adding a generic webhook would have made the diagram green, but it would also have accepted whatever JSON anybody sent to the URL. CrowdRelay already signs outbound requests, so the ingress needed to preserve and verify the exact request rather than merely check that a header existed.

The signature is HMAC-SHA256 over:

timestamp + "." + exact raw request body

The raw body actually matters

The exact body matters. Parsing JSON and serialising it again may produce the same object with different bytes, which produces a different signature. The n8n webhook therefore captures the raw body before any friendly transformation happens.

The ingress checks the timestamp window, event ID, event type, schema version and signature. Signature comparison is constant-time. Unknown event types are rejected instead of being silently accepted and forgotten. A valid event is routed only to the branch assigned to that exact type.

Signed does not mean fresh

Replay protection needed its own decision. A properly signed request can still be resent. The ingress stores recently accepted event IDs and treats a duplicate as already handled. The sender gets a successful response, but the branch does not run for the second time. That is important for events that issue a pass, send a message or change operational state.

The responses are deliberately different. A fresh accepted event returns 202. A known duplicate returns 200. A broken signature, stale timestamp, inconsistent headers or unsupported version receives a 4xx. The worker can now distinguish “try again later” from “this request will never become valid”.

Visible is not the same as published

Publishing turned out to be another missing piece. In n8n 2.x the webhook and the referenced subworkflows need published versions. Having a workflow visible in the editor is not the same as having production execution available. I published the canonical branches and quarantined a duplicate handler for the same Discord reminder event instead of leaving two possible targets behind.

One ingress, one route, one branch

The final shape is simple:

CrowdRelay outbox worker→ signed HTTP request→ one verified n8n ingress→ exact event routing→ one branch workflow→ provider-specific delivery

There is no credential attached to every branch and no public webhook duplicated nineteen times. Verification happens once, routing happens once and the branch receives an event that has already crossed the security boundary.

This was a good reminder that a collection of handlers is not an integration. The interesting part lives between the systems: authentication, replay handling, versioning, retries, publication state and the very boring question of whether anything is listening at all.

The front door is now there. It also checks who is knocking.