Skip to content

Stripe Webhook Router

Overview

The Stripe Webhook Router (hns-stripe-microservice) is a small Go service that acts as the single entry point for Stripe webhook deliveries across the HNS platform. It verifies each event's Stripe signature once, then fans the raw payload out to the correct downstream system (Drupal webshop, ticketing backend, etc.) via the internal Event Bus.

  • Repository: hns-stripe-microservice/
  • Language: Go (single container)
  • External interface: POST /webhook/stripe (public), GET /healthcheck
  • Downstream: publishes to NATS JetStream on stripe.<destination> subjects

Why a Separate Router

Before this service, every system that needed Stripe webhooks had to register its own URL with Stripe, re-implement signature verification, and share the webhook signing secret. That created three problems:

  1. Duplicate signature code. Drupal and the ticketing backend both called Stripe\Webhook::constructEvent(...) with slightly different conventions.
  2. Fragile re-delivery. If one destination was down and Stripe retried, the other destination might double-process.
  3. Hard to add consumers. Any new service needed a Stripe dashboard change, a new signing secret, and PHP/Node code to verify signatures.

Centralizing the router gives one trust boundary (Stripe ↔ router) and pushes downstream delivery onto the internal Event Bus, which already handles retries, buffering, and multi-destination fan-out.

Architecture

┌───────────┐
│   Stripe  │
└─────┬─────┘
      │ POST /webhook/stripe
      │ Stripe-Signature: t=...,v1=...
      ▼
┌───────────────────────────────────────────┐
│   hns-stripe-microservice (Go)            │
│                                           │
│   1. Read raw body + Stripe-Signature     │
│   2. Verify signature with STRIPE_        │
│      WEBHOOK_SECRET                       │
│   3. Parse metadata.site from payload     │
│   4. Look up site → subject in routes.yml │
│   5. Publish raw bytes to NATS subject    │
│   6. Return 200                           │
└────────────┬──────────────────────────────┘
             │ publish to
             │ "stripe.<destination>"
             ▼
┌───────────────────────────────────────────┐
│   NATS JetStream                          │
│   (stream: hns_ticketing_events)          │
└────────────┬──────────────────────────────┘
             │ eventbus consumer picks up
             ▼
┌───────────────────────────────────────────┐
│   Eventbus consumer subscription          │
│   (e.g. stripe-drupal, stripe-backend)    │
│                                           │
│   • wraps in envelope                     │
│   • POSTs with Bearer token               │
└────────────┬──────────────────────────────┘
             │
     ┌───────┴────────┐
     ▼                ▼
┌─────────┐    ┌──────────────┐
│ Drupal  │    │ Ticketing    │
│ webshop │    │ backend      │
└─────────┘    └──────────────┘

Trust and Security Boundaries

There are two distinct secrets in this flow:

Secret Purpose Held by
STRIPE_WEBHOOK_SECRET (whsec_...) Stripe-issued signing secret. Verifies inbound requests came from Stripe. Router only.
<DEST>_WEBHOOK_SECRET (Bearer) Internal shared secret between the eventbus consumer and each destination. Consumer env + each destination's auth checker.

The router is authoritative for Stripe signature verification. Downstream destinations must not re-verify the Stripe signature — by the time the event reaches them, the Stripe-Signature header has been stripped, and any attempt to re-verify will fail. Destinations instead check the Bearer token to confirm the request came from the eventbus consumer.

Destinations that historically had direct Stripe webhooks (e.g. Drupal's commerce_stripe.webhook_signing_secret) should leave their Stripe signing secret empty in environments where the router is the source of truth — otherwise the destination will attempt a double-verification against a request that no longer has the original headers.

Routing Configuration

The router reads routes.yml at startup and hot-reloads on change (via fsnotify). The file maps NATS subjects to lists of metadata.site values:

stripe.drupal:
  - "shop.hns.family"
  - "dev.shop.hns.family"
  - "staging.shop.hns.family"
  - "hns.docker.localhost"

# stripe.backend:
#   - "api.hns.family"
#   - "api.docker.localhost"

When a Stripe event arrives, the router:

  1. Parses data.object.metadata.site from the payload.
  2. Looks up that string in the reverse map built from routes.yml.
  3. Publishes the raw Stripe payload to the matched subject (e.g. stripe.drupal).

If metadata.site is missing, unknown, or empty, the router returns 200 OK with an acknowledgement message — we explicitly do not 4xx these so Stripe doesn't retry a fundamentally unroutable event. NATS publish failures return 500, which does trigger Stripe's exponential retry (up to 72h).

Producer contract

For events to be routable, producers must set metadata.site when creating the PaymentIntent (or Charge, or SetupIntent — whichever object Stripe fires the event for). Example from the ticketing backend's PaymentService::createPaymentIntent:

PaymentIntent::create([
    'amount' => $amount,
    'currency' => 'eur',
    'metadata' => [
        'site' => 'api.hns.family',   // routes to stripe.backend
        'order_id' => $order->getId()->toRfc4122(),
        // ...
    ],
]);

The site value should be a string that uniquely identifies the originating system/environment, and must appear in routes.yml under the target NATS subject.

Downstream Destinations

Drupal webshop

The Drupal hns_custom module receives stripe.drupal events at POST /stripe-webhook:

  • Access check: WebhookSecretAccess validates the Authorization: Bearer <DRUPAL_WEBHOOK_SECRET> header.
  • Envelope: controller reads the .data field from the eventbus envelope to get the original Stripe payload.
  • Business logic: order completion, wallet top-ups, payment creation.

Ticketing backend

The backend receives stripe.backend events at POST /webhooks/stripe:

  • Access check: StripeWebhookController validates the Authorization: Bearer <BACKEND_WEBHOOK_SECRET> header.
  • Envelope: controller unwraps the eventbus envelope before calling PaymentService::handlePaymentSuccess / handlePaymentFailure.
  • Idempotency: the StripeWebhookLog entity guards against duplicate processing, complementary to JetStream deduplication on the bus side.

Error Handling

Scenario Router response Notes
Invalid Stripe signature 400 Bad Request Logged at WARN; Stripe will not retry
Missing metadata.site 200 OK Acknowledged; event dropped
metadata.site not in routes.yml 200 OK Acknowledged; event dropped
NATS publish fails 500 Internal Server Error Stripe retries with exponential backoff up to 72h
YAML config reload parse error N/A Previous config retained; error logged

Operational Notes

  • API version skew: the router uses webhook.ConstructEventWithOptions(..., IgnoreAPIVersionMismatch: true). Because the router only routes the raw payload and does not deserialize it, a mismatch between the Stripe account's API version and the stripe-go build must not block delivery.
  • Hot-reload: edits to routes.yml take effect within a few seconds. If the new file fails to parse, the previous config is kept and an error is logged — no restart required, no downtime.
  • Health: /healthcheck returns 200 ok when the NATS connection is up, 503 otherwise. Checked by the container's Docker healthcheck every 10s.

Adding a New Destination

Standard pattern — no router code changes needed.

  1. Pick a subject name (stripe.<destination>).
  2. Add the site value(s) to routes.yml under that subject.
  3. Configure producers to set metadata.site to one of those values on their Stripe calls.
  4. Add a subscription to hns-ticketing-eventbus/config.d/ (URL, Bearer header, retry policy).
  5. Implement a destination handler that validates the Bearer header and unwraps the eventbus envelope.

See Event Bus for the consumer/envelope details.


Last Updated: April 2026