Skip to content

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:

  1. Connects to NATS and declares a durable JetStream consumer (named after consumer: in the YAML).
  2. Subscribes to the configured subject (wildcard-supported).
  3. For each message: wraps the raw payload in an envelope, POSTs to the webhook.url with configured headers.
  4. 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-all
  • MAILER_API_KEY → mailer (uses X-API-Key rather 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 MaxDeliver attempts: 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: /healthz on the consumer reports ready once it has subscribed to all configured subscriptions.
  • NATS metrics: exposed on port 8222; scraped by the nats-exporter sidecar into Prometheus.
  • Logs: consumer emits JSON via log/slog to 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.


Last Updated: April 2026