Event Bus¶
Overview¶
The HNS Ticketing platform uses a lightweight internal event bus — a NATS JetStream server plus a Go consumer ("nats-webhook") that forwards subjects to downstream HTTP webhooks. The bus sits between producers (the ticketing backend, the Stripe webhook router, mailer integrations) and destinations that need to react to those events without being tightly coupled to the producer.
- Repository:
hns-ticketing-eventbus/ - Transport: NATS 2.11 with JetStream enabled (persistent, replayable, ordered per subject)
- Consumer: single multi-subscription Go process (
consumer/main.go) - Destinations: Drupal webshop, ticketing backend, mailer microservice
Why an Event Bus¶
The platform has multiple systems that need to react to each other's state changes: Stripe payment events affect Drupal and the ticketing backend; the ticketing backend produces domain events (orders, tickets, matches, loyalty, blacklist changes) that trigger notifications, partner integrations, and downstream workflows; mailer templates must be rendered by a separate mailer service. Point-to-point HTTP coupling between each producer-consumer pair would not scale.
| Need | Direct HTTP | Event Bus |
|---|---|---|
| Drupal and backend both react to Stripe events | Configure two Stripe endpoints, duplicate signature verification | One verification at the edge, fan-out by subject |
| Backend domain event drives multiple reactions (notification, audit, partner sync) | Backend must know every consumer and call each | Publish once; any number of subscriptions consume |
| Downstream service is down | Producer handles retries / failures | Bus buffers; producer stays unaware |
| Add a new consumer | Change producer code | Add a subscription YAML file |
| Replay events | Not supported | JetStream replays within retention window (72h) |
Architecture¶
The bus is a fan-in / fan-out hub. Multiple producers publish to the hns_ticketing_events stream; multiple subscriptions each forward a subset of subjects to a dedicated HTTP destination. Systems commonly appear on both sides — notably the ticketing backend, which is both the largest publisher (domain events across nine subject namespaces) and a subscriber (via the catch-all > subscription that feeds its internal event-driven workflows, plus stripe.backend for incoming Stripe events).
Publishers Subscriptions Destinations
┌───────────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Stripe Webhook Router │ stripe.drupal │ stripe-drupal │────▶│ Drupal webshop │
│ │ stripe.backend ├──────────────────┤ └─────────────────┘
└───────────┬───────────┘ │ mailer-auth- │ ┌─────────────────┐
│ │ events │────▶│ Mailer │
│ publish │ (mail.>) │ │ microservice │
▼ ├──────────────────┤ └─────────────────┘
┌───────────────────────────────────────┐ │ backend-all- │ ┌─────────────────┐
│ NATS JetStream │ │ events │────▶│ Ticketing │
│ stream: hns_ticketing_events │ │ (> catch-all │ │ backend │
│ subjects: │ │ includes │ └─────────────────┘
│ ticket.> order.> payment.> │ │ stripe.backend) │
│ match.> queue.> quota.> │ └──────────────────┘
│ blacklist.> loyalty.> │ ▲
│ mail.> stripe.> │ │ JetStream
│ retention: 72h │ │ durable consumers
└───────────────────────────────────────┘ │
▲ │
│ publish │
│ │
┌───────────┴───────────┐ ┌─────────┴────────┐
│ Ticketing Backend │ │ nats-consumer │
│ ticket.> order.> │ │ (Go) │
│ payment.> match.> │ │ │
│ queue.> quota.> │ │ • reads │
│ blacklist.> loyalty.>│ │ config.d/*.yml│
│ mail.> │ │ • one goroutine │
└───────────────────────┘ │ per subscript.│
│ • wraps payload │
│ in envelope │
│ • POSTs with │
│ Bearer token │
└──────────────────┘
Components¶
NATS JetStream¶
Single-node NATS server in local and Nebion environments. One stream (hns_ticketing_events) captures every subject the platform publishes today.
| Setting | Value | Purpose |
|---|---|---|
| Subjects | ticket.>, order.>, payment.>, match.>, queue.>, quota.>, blacklist.>, loyalty.>, mail.>, stripe.> |
Wildcard subjects covering all domain events |
| Retention | limits / 72h |
JetStream keeps messages up to 72h for consumer replay |
| Storage | File-backed volume (nats_data) |
Survives container restart |
| Monitoring | HTTP port 8222 (/healthz, /varz, /jsz) |
Scraped by Prometheus via natsio/prometheus-nats-exporter |
Subject namespaces follow a domain prefix pattern (<domain>.<event> — e.g. order.completed, mail.welcome, stripe.drupal). Producers are free to publish any <domain>.* subject; the stream captures all of them.
Consumer (nats-consumer)¶
A single Go binary (consumer/main.go, ~400 lines) that reads YAML subscription files from config.d/, establishes one JetStream consumer per subscription, and forwards each message over HTTP.
For each subscription it:
- Connects to NATS and declares a durable JetStream consumer (named after
consumer:in the YAML). - Subscribes to the configured subject (wildcard-supported).
- For each message: wraps the raw payload in an envelope, POSTs to the
webhook.urlwith configured headers. - On HTTP 2xx →
msg.Ack(). On 4xx/5xx or network error →msg.Nak()(redelivery governed by JetStream).
Subscription Configuration¶
Subscriptions live in config.d/*.yml. Files are loaded in filename order at startup and applied via os.ExpandEnv substitution so ${VAR} placeholders resolve from the consumer container's environment.
subscriptions:
- name: stripe-drupal
subject: "stripe.drupal"
consumer: stripe-drupal-consumer
webhook:
url: "${DRUPAL_STRIPE_WEBHOOK_URL}"
headers:
Authorization: "Bearer ${DRUPAL_WEBHOOK_SECRET}"
timeout: "15s"
retries: 3
backoff: "2s"
max_concurrency: 50
ack_wait: "120s"
Current subscriptions:
| File | Subject | Destination | Purpose |
|---|---|---|---|
10-backend.yml |
> (all) |
Ticketing backend /api/v1/webhooks/nats |
Catch-all for backend-internal event processing |
20-mailer.yml |
mail.> |
Mailer microservice /mail-template |
Transactional email rendering + delivery |
30-stripe-drupal.yml |
stripe.drupal |
Drupal hns_nginx/stripe-webhook |
Stripe payment events for the HNS webshop |
Adding a destination is a config-only change: drop a new config.d/NN-<name>.yml, add the needed env vars to the consumer's environment: block in docker-compose.yml, and restart.
Envelope Format¶
Downstream destinations do not receive the raw producer payload. The consumer wraps every message in a standard envelope before POSTing:
{
"subject": "stripe.drupal",
"data": { ... original producer payload ... },
"timestamp": "2026-04-21T08:09:50Z",
"message_id": "nats-msg-12345"
}
Destinations must unwrap .data to access the original payload. The envelope carries metadata the producer may not have known (subject, message ID for correlation).
Authentication¶
The consumer → destination hop uses Bearer token authentication via the Authorization header. Secrets are per-destination and live in env vars:
NATS_WEBHOOK_SECRET→ ticketing backend catch-allMAILER_API_KEY→ mailer (usesX-API-Keyrather than Bearer)DRUPAL_WEBHOOK_SECRET→ Drupal/stripe-webhook
Destinations are responsible for rejecting requests with missing/invalid tokens. For Stripe events specifically, the Stripe signature is verified upstream in the Stripe webhook router — the Bearer token here only protects the internal eventbus-to-destination hop. See Stripe Webhook Router for the full trust boundary.
Retry and Delivery Semantics¶
Each subscription declares retries: N and backoff: D, which the consumer translates into a JetStream BackOff schedule ([D, D*2, D*4]) and MaxDeliver: N+1.
- On HTTP 2xx: ack, message is removed from the consumer.
- On HTTP 4xx/5xx or network error: nak; JetStream redelivers after the backoff.
- After
MaxDeliverattempts: message is dead-lettered (dropped from the stream). ack_wait(default 120s) governs how long JetStream waits for an ack before assuming the delivery failed.
Networks¶
The consumer attaches to every Docker network where it has a destination:
| Network | Why |
|---|---|
hns-ticketing-backend_hns-ticketing |
Reaches NATS, ticketing backend, admin portal, quota portal, mailer |
hns_default |
Reaches the Drupal webshop (hns_nginx) for stripe-drupal subscription |
New destinations on new networks require the consumer's networks: list to be extended.
Observability¶
- Health:
/healthzon the consumer reports ready once it has subscribed to all configured subscriptions. - NATS metrics: exposed on port 8222; scraped by the
nats-exportersidecar into Prometheus. - Logs: consumer emits JSON via
log/slogto stdout; the Alloy sidecar ships both NATS and consumer logs to Loki.
Relationship to the Monolith Strategy¶
The ticketing backend remains a modular monolith; see Architecture Overview for the reasoning. The event bus is deliberately not a microservices split of the backend's internal logic — it's an integration seam between the backend and systems it does not own (Drupal, Stripe, mailer, future federated services). Cross-module coordination inside the ticketing backend continues to use domain events + outbox; the NATS bus is for cross-system coordination.
Related Documentation¶
- Stripe Webhook Router — the first producer of
stripe.*events - E15-F1: Transactional Email Service — consumes
mail.> - Microservices Strategy
- Architecture Overview
Last Updated: April 2026