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-consumersubscribes toEVENTS_QUEUEand 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 anX-BC-Subs-Signatureheader, plusX-BC-Subs-Timestamp,X-BC-Subs-Event-Id, andX-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.