Skip to content

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 substratelogEvent() writes to D1 first (source of truth), then publishes to EVENTS_QUEUE, with a queue_published_at outbox 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 verificationwebhook-id/webhook-timestamp/webhook-signature headers, 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_secret of whichever client POSTed /v3/hooks; for store-level API-account installs that differs from BC_CLIENT_SECRET, so BC_WEBHOOK_SIGNING_SECRET exists 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.yaml is generated, never hand-editedopenapi/generate.ts builds 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-level derives_from pins worker.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 carries sign_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 header x-bc-webhook-secret that 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-checked openapi.yaml still 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-1939 still states the signing key is "utf8 bytes of the base64-encoded BC_CLIENT_SECRET" (the pre-#1402 model), while the operative comment at webhooks.ts:1979-2000 and the handleBcWebhook docblock at webhooks.ts:2038-2049 correctly describe the fixed, per-registering-client model. A reader who stops at the first block gets the wrong mechanism.
  • Built-but-untroddenstore/shipment/created and store/channel/{created,updated,deleted} all have real, dispatchable handlers in the HANDLERS map (webhooks.ts:1900-1915), but none of the three independent scope-registration lists that actually call POST /v3/hooks on install (SCOPES_TO_REGISTER in oauth.ts, EXPECTED_WEBHOOKS in onboarding.ts, RE_REGISTER_SCOPES in registration-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', ...) in webhook-delivery.test.ts; the coverage matrix row (US-27.3, epic 27) reads g4-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-health admin page's webhook status is synthesized, not queried: handleOnboardingHealth sets every EXPECTED_WEBHOOKS entry to status: '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 whose store/shipment/created scope 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 residueADR-0010 specifies a dedicated Workers-Queue-consumer outbound delivery Worker, four X-BC-Subs-* signed headers, and an encrypted + 24h-overlap-rotatable signing_secret_encrypted column on a merchant_webhook_subscriptions table. What shipped (commits e652d49d/ f1091759) is a cron sweep inside the API Worker (cron/webhook-deliveries.ts, 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. 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 HANDLERS map in routes/webhooks.ts, then — this is the step the two existing untrodden handlers skipped — add the scope to all three of SCOPES_TO_REGISTER (oauth.ts), EXPECTED_WEBHOOKS (onboarding.ts), and RE_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_TYPES in services/webhook-delivery.ts, then call enqueueDelivery(env, store_hash, eventType, payload) at the lifecycle call site (after the matching logEvent for the same transition).
  • Inbound deliveries silently failing? Check BC_WEBHOOK_SIGNING_SECRET vs BC_CLIENT_SECRET first — a store-level API-account install signs with its own client, not the app's. webhooks.ts:2056 is 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). A pending row with a future next_retry_at waits 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 verifyStandardWebhook must 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; the query_filter DSL 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_SCOPES in registration-health.ts is narrower than the other two registration lists (4 scopes vs. SCOPES_TO_REGISTER's 7 and EXPECTED_WEBHOOKS's 6 — it omits store/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 reading registration-health.ts:60-65 and 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 own procedure_evidence documents having read them via git 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.