Skip to content

5. The event backbone

Generated from a canonical source

This page is a read-only projection of docs/solution-review/05-event-backbone.md. Edit the canonical file, then run npm --prefix tools/project-knowledge-derive run derive.

Money moves on the charge rail (04-money-movement.md); everything that has to happen because money moved — or because an order, customer, or shipment changed on the store — moves on the event backbone. There are three legs: BigCommerce webhooks flowing in, processor events normalized into a small internal vocabulary, and domain events flowing back out to merchant webhooks and email.

flowchart LR
    BC["BigCommerce platform"]
    Recv["subs-api<br/>/webhooks/bc receiver<br/>(HMAC verify, dispatch by scope)"]
    OrderC["store/order/created<br/>→ create subscription + charges"]
    OrderU["store/order/updated<br/>→ sync order state"]
    Ship["store/shipment/created<br/>→ capture (on_ship)"]
    Cache["product/* + channel/*<br/>→ KV cache bust"]
    CustC["store/customer/created<br/>→ snapshot customer"]
    State[("Domain state<br/>D1: subscriptions,<br/>charges, events")]
    Q{{"EVENTS_QUEUE"}}
    Email["subs-email-consumer"]
    MWH["Merchant webhooks<br/>(designed, ADR-0010)"]

    BC -->|"signed webhook (standardwebhooks v1)"| Recv
    Recv --> OrderC
    Recv --> OrderU
    Recv --> Ship
    Recv --> Cache
    Recv --> CustC
    OrderC --> State
    OrderU --> State
    Ship --> State
    CustC --> State
    State -->|"logEvent"| Q
    Q -->|"consumes"| Email
    Q -.->|"designed"| MWH

Inbound: what BigCommerce sends, and what we do with it

The app registers exactly these webhook subscriptions with BigCommerce at install time. The list is the single source of truth in apps/api/src/routes/webhooks.ts (WEBHOOK_REGISTRY, from which SCOPES_TO_REGISTER is derived); the scopes below are verbatim from it.

Scope (registered at install) What fires it What the app does
store/order/created A BigCommerce order is placed Fetch subscription intent from the cart metafields, create the subscription plus its cycle-0 (paid at checkout) and cycle-1 (pending renewal) charges
store/order/updated An order's status or contents change Sync order status into the subscription's charge/order view; audit order edits
store/product/updated Any field on a product changes (including category assignment) Bust the product→categories KV cache
store/product/deleted A product is deleted Bust the product→categories KV cache
store/customer/created A new customer record is created Snapshot the customer record for subscriber lookup
store/channel/created A storefront channel is created Bust the channel-list KV cache
store/channel/updated A channel is updated Bust the channel-list KV cache
store/channel/deleted A channel is deleted Bust the channel-list KV cache
store/shipment/created A shipment is created against an order Fire deferred capture() for on_ship-mode subscriptions (guarded; see capture timing in 04-money-movement.md)

Three scopes exist in the platform's vocabulary but are not live /v3/hooks subscriptions — for three different, honest reasons:

Scope State Why
store/app/uninstalled Delivered, but not a /v3/hooks subscription BigCommerce delivers uninstall via the app's static Uninstall Callback URL; the app purges credentials and offboards there. No registration needed
store/cart/*, store/customer/updated Registration deliberately pulled These had no handler; real deliveries were being silently acked and dropped. Registration was removed (#2006) rather than left dropping events on the floor. Reversible once a handler is built
store/order/refund/created Planned, not yet subscribed The refund-reconciliation webhook was never stood up. Confirmed absent in code; it must be built to reconcile processor-side refunds back onto the charge history. Tracked as an open assumption in docs/gsi-package/05-bc-platform-integration-contract.md §5.6

Every inbound delivery is verified with BigCommerce's standardwebhooks v1 signature scheme (webhook-id / webhook-timestamp / webhook-signature headers). The HMAC key is the client_secret of whichever OAuth client registered the hook — so multi-store deployments key verification per merchant, resolved at delivery time from BC_WEBHOOK_SIGNING_SECRET, falling back to BC_CLIENT_SECRET.

Processor-side events: normalize before you consume

Gateways speak their own webhook dialects — Stripe emits payment_intent.succeeded / payment_method.detached / charge.dispute.created; the BC Payments path exposes settled/declined/disputed transaction events. Each processor adapter normalizes its own stream into one small shared vocabulary of four events before anything internal sees it:

  • charge settled
  • charge declined
  • payment method invalidated
  • dispute opened

The billing engine and the notification layer consume only this normalized vocabulary, never a gateway-specific event shape. That is what keeps adding a gateway an adapter exercise rather than a change that ripples through the engine. One honest edge to note: a decline on the BC-native rail returns only a generic platform decline shape with no processor-specific reason code, so the app's decline classifier receives nulls and treats it as soft/retry (safe by policy — never silently drop a subscriber). Only the direct-Stripe edge adapter, talking to Stripe's API directly, gets granular decline codes. Source: docs/gsi-package/05-bc-platform-integration-contract.md §5.5.

Outbound: the egress substrate

Domain state changes are recorded by logEvent, which writes the events row in D1 (the source of truth) and publishes to EVENTS_QUEUE in the same request. Cloudflare Queues is the one substrate for both internal fan-out and merchant-facing egress (ADR-0010). The split matters:

  • Internal fan-out is live. subs-email-consumer subscribes to EVENTS_QUEUE and sends transactional email. This path runs today.
  • Merchant-facing webhook egress is designed, not yet live. ADR-0010 specifies a dedicated outbound delivery worker that fans out per merchant subscription and signs each POST — HMAC-SHA256 over <timestamp>.<body> in an X-BC-Subs-Signature header, plus X-BC-Subs-Timestamp, X-BC-Subs-Event-Id, and X-BC-Subs-Schema-Version, with a per-merchant signing secret and a fixed retry-then-DLQ schedule (1m / 5m / 15m / 1h / 6h / 24h, dead-lettered after 7 attempts). ADR-0010's own Consequences scope the delivery worker itself to the Epic-27 developer platform — the substrate and contract are settled; the worker is not shipped.

The reconciliation half is shipped. logEvent marks each row with events.queue_published_at; if the queue publish throws (transient fault, binding briefly absent), the row stays unmarked and a catch-up cron (republishUnpublishedEvents, every 60 seconds, batches of 50 with a 30-second grace window) republishes it. This is the transactional-outbox pattern ratified in ADR-0027, which closed the silent-data-loss gap in ADR-0010 §1. Deep dives: ../decisions/0010-webhook-event-egress.md, ../decisions/0027-outbox-marker-and-catchup-cron.md.

Delivery-guarantee posture

The backbone is at-least-once, end to end. D1 is the durable source of truth; the queue is the delivery substrate; the outbox marker plus catch-up cron recover any publish that failed. Because at-least-once permits duplicate delivery — a successful publish whose mark write fails is republished on the next cron pass — every consumer must be idempotent on event_id. Inbound handlers are likewise idempotent against BigCommerce's own retries (up to 12 redeliveries), keyed on the order/subscription identity. Outbound signatures carry a 5-minute replay window (now() - timestamp > 300 is rejected). Full contract: ../decisions/0010-webhook-event-egress.md.