Webhooks and integrations¶
Generated from a canonical source
This page is a read-only projection of docs/handoff-corpus/webhooks-and-integrations.md.
Edit the canonical file, then run npm --prefix tools/project-knowledge-derive run derive.
What webhooks-and-integrations is for¶
The invariant you must not break: no inbound BC webhook payload is trusted
before its signature verifies against the signing identity of the OAuth
client that actually registered the hook. handleBcWebhook computes the
expected HMAC before touching payload.scope or dispatching to any handler,
and returns 401 on any mismatch (webhooks.ts:2051-2076). This is not a
hypothetical failure mode — it happened in production: before Hive
1402/#1407, real BC deliveries against sandbox cdfqf9k6zf were silently¶
rejected because the key derivation was wrong, and separately, because the
code assumed one app-wide secret when the actual signer was a store-level API
account's own client_secret. Getting it wrong the other way — accepting an
unverified or wrongly-keyed payload — would let a forged delivery create or
mutate subscription/charge state under the store/order/created and
store/order/updated handlers. (webhooks.ts:2038-2076; commits a665e9d5,
051bd982.)
This domain covers two directions of traffic plus the contract that describes them, reader-facing:
- Real-time reaction to storefront and catalog changes — the app hears
about new orders, order edits, shipments, and product/channel changes from
BigCommerce the moment they happen, without polling (US-1.4,
store/order/created - 5 more scopes)
- Developer/merchant-registered outbound webhooks — a third-party integration or merchant tool subscribes to subscription lifecycle events (created, charged, past_due, cancelled, paused, resumed) and gets a signed HTTP POST when they fire, with per-subscription field/state/payload filters (US-23.8, US-27.3)
- Self-describing public API contract — the REST + GraphQL surface a headless storefront or third-party integrator builds against is generated from the same source that validates it in CI, not hand-maintained prose (US-27.1, US-27.5)
- Selective delivery, not "everything or nothing" — a webhook subscriber declares which event types, which changed fields, which current-state predicate, and which payload projection they want, so a high-volume merchant integration isn't forced to filter noise client-side (Hive #1395 / ADR-0010 amendment)
The decisions that carry the most weight, per ADR-0010 unless noted:
- Workers Queues transactional-outbox is the producer-side egress
substrate —
logEvent()writes to D1 first (source of truth), then publishes toEVENTS_QUEUE, with aqueue_published_atoutbox marker and a reconciliation cron for publish failures (§1;db.ts:3763-3814,cron/republish-unpublished-events.ts). - standardwebhooks v1, not a custom HMAC scheme, for inbound
verification —
webhook-id/webhook-timestamp/webhook-signatureheaders, HMAC-SHA256 over${id}.${ts}.${body}, ±5 minute replay window; corrected from an earlier broken key-derivation in production (Hive #1402). - Webhook signing identity is the registering OAuth client, not one
app-wide secret — BC signs with the
client_secretof whichever client POSTed/v3/hooks; for store-level API-account installs that differs fromBC_CLIENT_SECRET, soBC_WEBHOOK_SIGNING_SECRETexists as an explicit per-store override with fallback (Hive #1407,webhooks.ts:2038-2049). - Selective-delivery filters ship as the Day-1 contract, not a v2 add-on — type → field-change → current-state → projection, composed in order, all nullable so zero-config subscriptions are unaffected; bolting selectivity on later was rejected because it would migrate early subscribers across an implicit-contract change (Hive #1395 amendment, §3a).
openapi.yamlis generated, never hand-edited —openapi/generate.tsbuilds it from a zod-to-openapi registry, and CI (openapi-drift.yml) fails if the committed file doesn't match a fresh generation; the drift check catches accidental hand-edits, not a route registration that itself describes the wrong contract (see Move 3).
Canonical-framing attestation (operator-ratified 2026-07-02). The inbound
receiver (POST /webhooks/bc, webhooks.ts::handleBcWebhook +
verifyStandardWebhook) is the sole and canonical inbound mechanism — this
is settled and unambiguous. Outbound egress is a genuinely open,
unreconciled framing gap, not a settled canonical-vs-residual split.
ADR-0010 is the only deciding record for outbound egress, and it specifies a
dedicated Workers-Queue-consumer delivery Worker, four X-BC-Subs-* signed
headers, and an encrypted, rotatable signing_secret_encrypted column on a
merchant_webhook_subscriptions table. What actually shipped for
US-23.8/US-27.3 (commits e652d49d/f1091759) is architecturally
different: a cron sweep running inside the API Worker itself with no queue
consumer, a single Stripe-style X-Subs-Signature: t=<ts>,v1=<hex> header,
and an HKDF-derived, non-rotatable secret on a plain webhook_subscriptions
table. The Hive #1395 amendment inside ADR-0010 addresses only the later
selective-delivery filters — it does not reconcile the queue-vs-cron or
header-scheme gap. ADR-0010 remains the only ratified record, but it does not
describe the artifact running in production. Read the ADR for stated
intent; read this page's Move 2 for what actually runs.
How it actually works¶
Inbound receiver. POST /webhooks/bc
(routes/webhooks.ts::handleBcWebhook)
reads the raw body, verifies it against verifyStandardWebhook, then
dispatches by payload.scope through the HANDLERS map
(webhooks.ts:1900-1915). Verification resolves the HMAC key to
BC_WEBHOOK_SIGNING_SECRET when set, otherwise BC_CLIENT_SECRET
(webhooks.ts:2056), computes HMAC-SHA256(${webhook-id}.${webhook-timestamp}.${rawBody}),
and constant-time-compares it against every v1,<sig> entry in the
webhook-signature header (webhooks.ts:1954-2036) — multiple entries
support key rotation, and BC's own timestamp guard rejects deliveries more
than 5 minutes stale. An unrecognized scope acks 200 with status:
'unknown_scope' (to stop BC's retry loop) rather than 401 or 500
(webhooks.ts:2089-2098); a handler that throws returns 500 so BC retries
(webhooks.ts:2106-2116).
Read the inbound path as: BC POSTs a signed delivery → the Worker
verifies the standardwebhooks signature before looking at the payload at all
→ a bad signature is a flat 401 → a good signature dispatches by scope to
one of seven registered handlers → the handler's own DB writes (and any
logEvent call) are the actual effect.
sequenceDiagram
autonumber
participant BC as BigCommerce (store/order/created webhook)
participant Worker as Worker.fetch() (worker.ts handleFetch)
participant WH as routes/webhooks.ts::handleBcWebhook
participant Verify as standardwebhooks signature verify
participant OrderH as handleOrderCreated (order webhook handler)
participant BCApi as api.bigcommerce.com (order / cart-metafields / transactions GET)
participant DB as D1
participant Queue as EVENTS_QUEUE
BC->>Worker: POST /webhooks/bc (scope: store/order/created, signed payload)
Worker->>WH: handleBcWebhook(request, env) (worker.ts:540-542)
WH->>Verify: verify HMAC signature (v1,<base64> over raw body)
alt bad signature
Verify-->>WH: fail
WH-->>BC: 401
else signature ok
WH->>OrderH: dispatch by scope → handleOrderCreated
OrderH->>BCApi: GET order, cart-metafields, transactions (stubbed in CI · real HTTP in prod)
BCApi-->>OrderH: order + line item metafields carrying encoded subscription intent
Note over OrderH: intent decoded via decodeSubscriptionIntents (shared contract package @bc-subscriptions/storefront-contract) — same encode/decode pair the storefront uses, closing the prior wire-contract drift.
OrderH->>DB: createSubscription (subscriptions row, status per plan: 'active' or 'trialing')
OrderH->>DB: createCharge (cycle 0 charge row, chain_position:'initial')
OrderH->>DB: logEvent('subscription.created', actor_kind:'system', payload:{from_order_id})
OrderH-->>Worker: 200 { subscription_id, ... }
Worker-->>BC: 200
end
Diagram provenance. Transcluded verbatim from § 2 "Subscribe — order/created webhook → subscription creation" of the canonical, code-sourced
docs/architecture/sequence-diagrams.md(file-levelderives_frompinsworker.ts,webhooks.ts,db.ts, among others — the frontmatter is file-level, not per-section, so every diagram transcluded from this source shares one provenance). Its frontmatter carriessign_off: pending— accurate to the code, not yet human-attested. This diagram covers the inbound receiver's dispatch path only; no dedicated outbound-egress or webhook-signature-verification diagram exists in the corpus as of this trace (per this domain's Input-B visual-aid pointer) — the outbound mechanism below is prose-only.
Which scopes actually arrive. The HANDLERS map dispatches seven scopes
(webhooks.ts:1900-1915): store/order/created, store/order/updated,
store/product/updated, store/product/deleted, store/shipment/created,
and the three store/channel/* events. But only six of those are ever asked
for at install — SCOPES_TO_REGISTER in
oauth.ts:345-360 registers store/order/created,
store/order/updated, store/product/updated, store/product/deleted,
store/cart/created, store/cart/updated, store/cart/deleted against
POST /v3/hooks on install — no shipment or channel scope. (See Move 3
for what that means for the two unregistered handlers.)
Outbound delivery. There is no queue consumer for outbound egress. A
cron sweep,
cron/webhook-deliveries.ts::runWebhookDeliverySweep,
runs on the same per-minute tick as other sweeps, selects up to 20
webhook_delivery_attempts rows where status='pending' AND next_retry_at <=
now(), and calls
services/webhook-delivery.ts::deliverAttempt
per row. enqueueDelivery is the fan-out entry point — for a given
store_hash + eventType, it looks up matching webhook_subscriptions rows
and inserts one pending attempt per subscription, keyed by ${sub.id}:${eventType}:${payload.subscription_id ?? payload.id ?? now}
(webhook-delivery.ts:167-190).
deliverAttempt re-checks the subscription is still active, derives a
per-subscription HMAC key via HKDF from CREDENTIAL_ENCRYPTION_KEY and the
subscription's numeric id (deriveWebhookSigningKey,
webhook-delivery.ts:94-105), and signs ${timestamp}.${jsonBody} into a
Stripe-shaped t=<ts>,v1=<hex> value sent as the single
X-Subs-Signature header (signPayload, webhook-delivery.ts:129-140).
The POST carries a 30-second timeout; a 2xx marks the attempt succeeded, a
non-429 4xx marks it exhausted (no retry — treated as developer
mis-config), and a 5xx/429/timeout/network error retries with exponential
backoff (60s, doubling, capped at 24h, exhausted after 8 attempts —
webhook-delivery.ts:69-80, 267-289). None of this — queue, headers, secret
storage — matches ADR-0010's specified shape; see the canonical-framing
attestation above and Move 3.
Read the outbound path as: a lifecycle event fires → enqueueDelivery
fans it out to one pending row per matching subscription → the per-minute
cron sweep picks up due rows → deliverAttempt signs and POSTs → success,
permanent failure, or a scheduled retry, entirely inside the API Worker.
Registration + secret handling.
routes/webhook-subscriptions.ts
is the developer-facing CRUD: POST /api/v1/admin/webhook-subscriptions
validates an HTTPS endpoint_url and a known events array, optionally
accepts the three Hive #1395 selective-delivery fields
(field_subscriptions, query_filter, payload_projection — all nullable),
inserts the row, then derives and stores a secret_hash (a verification
fingerprint, not the signing key itself — signing always re-derives from
CREDENTIAL_ENCRYPTION_KEY + the row's id). The response returns the
plaintext "secret" (in this implementation, the hash) exactly once; GET
responses expose only a secret_hint (last 4 hex chars).
Selective delivery. The four filters from the Hive #1395 amendment
(§3a) — event type, field-change, current-state predicate, payload
projection, composed in that order — are accepted and persisted at
registration time (routes/webhook-subscriptions.ts:130-144); events_json
gates fan-out in findWebhookSubscriptionsForEvent. query_filter is
stored opaquely (length-bounded to 2000 chars) with its DSL evaluator called
out in the route's own comment as a follow-on slice, not yet built.
Where intent and reality diverge¶
The derived coverage matrix
(_coverage-matrix.json) reports
US-1.4, US-23.8, and US-27.3 all at g4_status: pass. That is true,
and it is not the whole truth. Five typed deltas:
- Superseded-framing residue — the OpenAPI registration for
POST /webhooks/bc(openapi/routes-public.ts:101-125) declares a request headerx-bc-webhook-secretthat no code in the repository reads; the actual verifier uses the three standardwebhooks headers. The custom-header scheme is exactly the "earlier... stale" framing the handler's own header comment says was corrected via Hive #1402 (webhooks.ts:37-38) — but that fix touched the handler, not the OpenAPI registry, so the committed, CI-drift-checkedopenapi.yamlstill tells a third-party integrator the wrong inbound-auth contract. - Superseded-framing residue — two competing descriptions of the inbound
signing-key derivation coexist in the same file: the JSDoc block at
webhooks.ts:1917-1939still states the signing key is "utf8 bytes of the base64-encodedBC_CLIENT_SECRET" (the pre-#1402 model), while the operative comment atwebhooks.ts:1979-2000and thehandleBcWebhookdocblock atwebhooks.ts:2038-2049correctly describe the fixed, per-registering-client model. A reader who stops at the first block gets the wrong mechanism. - Built-but-untrodden —
store/shipment/createdandstore/channel/{created,updated,deleted}all have real, dispatchable handlers in theHANDLERSmap (webhooks.ts:1900-1915), but none of the three independent scope-registration lists that actually callPOST /v3/hookson install (SCOPES_TO_REGISTERinoauth.ts,EXPECTED_WEBHOOKSinonboarding.ts,RE_REGISTER_SCOPESinregistration-health.ts) include those scopes. A normally-installed store never asks BC to send them, so the handlers are reachable, tested-in-isolation code that has not fired on a live delivery. - Contract-verified, not live-verified — outbound merchant-webhook
delivery (US-23.8/27.3) G4-passes entirely against
vi.stubGlobal('fetch', ...)inwebhook-delivery.test.ts; the coverage matrix row (US-27.3, epic 27) readsg4-scenario/pass, but no e2e or sandbox test in the repo performs a real HTTP POST to an external subscriber endpoint. Inbound signature verification, by contrast, IS live-verified — see the live-state note below. The two halves of this domain sit at different evidence tiers and should not be quoted interchangeably. - Contract-verified, not live-verified — the
/onboarding/registration-healthadmin page's webhook status is synthesized, not queried:handleOnboardingHealthsets everyEXPECTED_WEBHOOKSentry tostatus: 'active'whenever the store row exists, per its own comment ("Until a registrations table exists, statuses come from 'the store row exists' → registered"). Combined with the prior delta, a merchant whosestore/shipment/createdscope was never registered (it can't be — it's absent from the registration list), or whose registration silently failed, sees a clean "active" health page regardless. - Superseded-framing residue — ADR-0010
specifies a dedicated Workers-Queue-consumer outbound delivery Worker,
four
X-BC-Subs-*signed headers, and an encrypted + 24h-overlap-rotatablesigning_secret_encryptedcolumn on amerchant_webhook_subscriptionstable. What shipped (commitse652d49d/f1091759) is a cron sweep inside the API Worker (cron/webhook-deliveries.ts, no queue consumer), a single Stripe-styleX-Subs-Signature: t=<ts>,v1=<hex>header, and an HKDF-derived, non-rotatable secret on a plainwebhook_subscriptionstable. No amendment records this divergence — the ADR's own Hive #1395 amendment addresses only the later selective-delivery filters. The ADR is the only ratified record but does not describe the artifact running in production.
Live-state note. Inbound signature verification is live-proven against
real BC traffic: commit a665e9d5 (Hive #1402) records posting a real order
to sandbox cdfqf9k6zf, capturing the live BC delivery headers via
wrangler tail, and the pre-fix Worker's own log line proving the delivery
was rejected in production before the fix. Commit 48dc7d77 records that
after 051bd982 (Hive #1407) shipped, live BC delivery was re-verified
correct. That proves signature verification + scope-dispatch reach and
correctly process live BC deliveries — it does not prove the
store/order/created handler's downstream subscription-materialization ran
live end-to-end starting from a real cart (see _input-b/subscribe.md's own
attestation for that boundary). Outbound merchant-webhook delivery has no
live-sandbox or e2e proof anywhere in the repo — every unit test drives
deliverAttempt against a stubbed fetch, and no real HTTP POST to an
external subscriber endpoint has ever been recorded in a commit, an ADR, or
a Hive issue found by this trace.
How to operate & extend¶
- Add a new inbound scope: add a handler to the
HANDLERSmap inroutes/webhooks.ts, then — this is the step the two existing untrodden handlers skipped — add the scope to all three ofSCOPES_TO_REGISTER(oauth.ts),EXPECTED_WEBHOOKS(onboarding.ts), andRE_REGISTER_SCOPES(registration-health.ts), or BC will never be asked to deliver it. - Add a new outbound event type: add it to
WEBHOOK_EVENT_TYPESinservices/webhook-delivery.ts, then callenqueueDelivery(env, store_hash, eventType, payload)at the lifecycle call site (after the matchinglogEventfor the same transition). - Inbound deliveries silently failing? Check
BC_WEBHOOK_SIGNING_SECRETvsBC_CLIENT_SECRETfirst — a store-level API-account install signs with its own client, not the app's.webhooks.ts:2056is the resolution order. - Outbound deliveries not firing? Start at
cron/webhook-deliveries.ts::runWebhookDeliverySweep— it is the only trigger; there is no queue consumer despite ADR-0010's design (see Move 3). Apendingrow with a futurenext_retry_atwaits for the next tick. - The invariant you must not break: no inbound payload is trusted before
its signature verifies against the signing identity of the client that
registered the hook (Move 1). Extending or refactoring
verifyStandardWebhookmust not move any payload access ahead of that check. - Extension seams: the selective-delivery filter chain (type →
field-change → current-state → projection,
ADR-0010§3a) is the registration-time contract to extend for new filter types; thequery_filterDSL evaluator itself is not yet built (registration persists it opaquely).
Confidence notes¶
- The outbound-egress divergence from ADR-0010 is reported as an open gap, not adjudicated as canonical-vs-residual. Input-B's own canonical-framing attestation is explicit on this: no later decision record performed the adjudication a corrected ADR normally provides (contrast with the inbound signing-key JSDoc delta, which is a clean superseded-framing case because Hive #1402/#1407 corrected it in code even though the doc comment lagged). This page follows that framing rather than resolving it — a reader should not treat the cron-sweep shape as "the new canonical design," only as "what is currently running."
RE_REGISTER_SCOPESinregistration-health.tsis narrower than the other two registration lists (4 scopes vs.SCOPES_TO_REGISTER's 7 andEXPECTED_WEBHOOKS's 6 — it omitsstore/product/{updated,deleted}as well as the untrodden shipment/channel scopes). Input-B's typed delta cites all three lists as missing shipment/channel; I traced the fourth discrepancy independently while readingregistration-health.ts:60-65and am noting it here rather than typing it as a new delta, since re-typing an Input-B-adjacent finding is out of scope for this generation pass.- I did not independently re-trace the live-state commit bodies (
a665e9d5,051bd982,48dc7d77) beyond reading Input-B's quoted excerpts — the live-state attestation's ownprocedure_evidencedocuments having read them viagit log -1 --format=%B, and I treated that as sufficiently load-bearing (per the contract's "human ratifies the trace" model) rather than re-running the same git commands to confirm identical output.