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:
- Duplicate signature code. Drupal and the ticketing backend both called
Stripe\Webhook::constructEvent(...)with slightly different conventions. - Fragile re-delivery. If one destination was down and Stripe retried, the other destination might double-process.
- 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:
- Parses
data.object.metadata.sitefrom the payload. - Looks up that string in the reverse map built from
routes.yml. - 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:
WebhookSecretAccessvalidates theAuthorization: Bearer <DRUPAL_WEBHOOK_SECRET>header. - Envelope: controller reads the
.datafield 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:
StripeWebhookControllervalidates theAuthorization: Bearer <BACKEND_WEBHOOK_SECRET>header. - Envelope: controller unwraps the eventbus envelope before calling
PaymentService::handlePaymentSuccess/handlePaymentFailure. - Idempotency: the
StripeWebhookLogentity 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.ymltake 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:
/healthcheckreturns200 okwhen the NATS connection is up,503otherwise. Checked by the container's Docker healthcheck every 10s.
Adding a New Destination¶
Standard pattern — no router code changes needed.
- Pick a subject name (
stripe.<destination>). - Add the site value(s) to
routes.ymlunder that subject. - Configure producers to set
metadata.siteto one of those values on their Stripe calls. - Add a subscription to
hns-ticketing-eventbus/config.d/(URL, Bearer header, retry policy). - Implement a destination handler that validates the Bearer header and unwraps the eventbus envelope.
See Event Bus for the consumer/envelope details.
Related Documentation¶
- Event Bus — delivery infrastructure downstream of the router
- E8-F1: Stripe Integration
- E4-F7: Payment Processing (Stripe)
- Architecture Overview
Last Updated: April 2026