Skip to content

Shop Commerce Integration (WooCommerce + MerchantPro)

Status: Built end-to-end (committed on staging, not pushed) as the connectivity slice (F14.1) of F14. Commerce & Access Offers. First real consumer of the Cat B (Connected Account, 1C.5) and Cat D (Inbound Webhook, 1C.6) frameworks.

Scope of this doc — the connectivity slice only. A clinic connects its external shop(s); the platform reliably receives → verifies → dedups → pulls paid orders and emits a commerce.order_paid internal event (Cat E). What happens to that event — mapping order line items to access (grants / subscriptions) — is the provisioning slice, specified separately under the access-offers design. This doc stops at the clean seam: a verified, authoritative, deduped paid order has arrived.

Why this exists

RestartiX clinics sell appointments, programs, and physical-product bundles (which may include free programs/sessions) through external e-commerce shops — today WooCommerce and MerchantPro, two shops per clinic. The shops stay external forever: the platform never builds a cart, checkout, payment, inventory, or shipping. Its responsibility begins at the fulfillment boundaryan order exists and is paid; turn it into platform access.

This is the paid-order trigger of the "one provisioning core, two triggers" design (the other trigger being free marketing-campaign claims). Both triggers converge on the same downstream provisioner; they differ only in the front door. This doc builds the shop front door up to the trigger event.

Locked decisions

  1. Cat B api_key, not OAuth. Both shops authenticate API reads with a consumer key + secret (HTTP Basic). The connected-accounts framework's auth_type='api_key' path is fully built; no OAuth-callback machinery is needed. Credentials are stored encrypted in organization_integrations.credentials_encrypted (AES-256-GCM via KMS).
  2. Verify-by-pull is the trust boundary. Neither the post-checkout redirect nor a webhook body is trusted for whether money changed hands. Money-truth always comes from an authenticated pull of the order from the shop API. The redirect order_key is a lookup + lightweight ownership token only.
  3. Per-connection inbound URL token + generated secret. Each connection gets a unique, unguessable inbound URL (/webhooks/{provider}/{token}) and a platform-generated signing secret. The token selects the connection deterministically before the body is read; the connection's secret then verifies the HMAC. The clinic pastes both the URL and the secret into its shop's webhook settings. (Chosen over a shared per-provider URL that sniffs the org from an unsigned payload field.)
  4. One normalized order contract. Woo and MerchantPro collapse to a single internal NormalizedOrder shape behind a connector interface; downstream code never sees the provider difference. (Mirrors the legacy order-provider.js normalization, hardened for multi-tenancy.)
  5. Per-org everything. Unlike the legacy single-clinic system (global env credentials), every credential, token, and order record is organization_id-scoped. The inbound URL token resolves which org + which credentials.
  6. Multiple connections of the same provider are supported. A clinic can connect N WooCommerce shops and N MerchantPro shops. The framework's uniqueness key is (organization_id, integration_service_id, external_account_id)external_account_id (required, non-empty; the shop's store_url) is the per-shop discriminator. One catalog row + one stateless connector impl per provider serves all of that provider's connections; each shop is its own organization_integrations row with its own credentials, inbound URL token + secret, and display title. The per-connection inbound token (decision 3) routes each shop's webhooks unambiguously — no payload sniffing, no store-URL collision risk. The unique key prevents connecting the same shop twice while allowing many distinct shops.

Architecture

  Shop (Woo / MerchantPro)                         API
  ────────────────────────                         ────────
  order paid ──push──▶  POST /webhooks/{provider}/{token}   ┌─ Cat D inbound ─┐
                          1. token → connection             │ verify.go       │
                          2. decrypt connection secret      │ parse.go        │
                          3. verify HMAC (raw body)         │ handler.go      │
                          4. dedup (provider, event_id)     └─────────────────┘
                          5. PULL authoritative order  ◀──── GET /orders/{id}   ┌─ Cat B ─┐
                             (consumer key/secret)                              │connector│
                          6. emit commerce.order_paid (Cat E)                   └─────────┘


                          [provisioning slice — separate doc]

The redirect-driven post-checkout onboarding page (where the buyer signs up + consents, and provisioning completes with the buyer present) is part of the provisioning slice, not this doc. This slice is the machine-to-machine connectivity that makes paid orders reliably available.

Track A — Cat B connectors (connect the shop)

Follow connected-account-integration-guide.md.

  • [ ] Seed catalog rows. Migration adding two integration_services rows: slug='woocommerce' and slug='merchantpro', auth_type='api_key', oauth_client_capability=NULL, status='available', config_schema describing store_url (+ provider-specific fields). Catalog mutations are AdminPool-only (written by migration).
  • [ ] Implement integrations.Connector for each, in internal/core/integrations/connectors/{woocommerce,merchantpro}/connector.go:
    • Slug()"woocommerce" / "merchantpro".
    • ValidateCredentials → require consumer_key, consumer_secret, store_url.
    • ValidateConfig → validate store_url shape.
    • Healthcheck → cheap authenticated GET (Woo: /wp-json/wc/v3/system_status; MerchantPro: a lightweight /api/v2 read), ≤5s timeout. This is the /test connection path.
    • RefreshOAuthTokennil, nil (non-OAuth).
    • init() calls integrations.Register(&Connector{}); import the package in cmd/api/main.go.
  • [ ] An order-fetch method on each connector (or a sibling ShopClient) — FetchOrder(ctx, creds, orderRef) (NormalizedOrder, error) — used by the inbound handler's pull step. Woo: GET /wp-json/wc/v3/orders/{id}; MerchantPro: GET /api/v2/orders/{id} + field normalization (see contract below).
  • [ ] No new connection endpoints — POST/GET/PATCH/DELETE /v1/organizations/{id}/integrations + /test already exist, gated by organizations.manage_integrations.

NormalizedOrder contract

NormalizedOrder {
  order_ref        string            // shop order id
  order_key        string            // redirect token / Woo order_key / MP public_code
  status           string            // normalized: paid | unpaid | refunded | cancelled | pending
  paid             bool              // derived; the only field the trigger gates on
  total, currency  string
  billing          { first_name, last_name, email, phone }
  line_items       [ { product_ref, variation_ref, sku, quantity } ]
}

Only paid == true orders emit the trigger. refunded/cancelled are recorded (for the future refund→revoke path) but do not provision. PII (billing.*) is persisted minimally and classified (see Compliance).

Track B — Cat D inbound (receive orders)

Follow inbound-webhook-guide.md. Reference consumer to copy: internal/integration/bunnystream/inbound/.

  • [ ] Per-connection inbound identity (the one net-new bit of framework wiring).
    • Add organization_integrations.inbound_url_token TEXT UNIQUE (indexed; generated at connection create). This is the {token} in the URL.
    • Store a generated inbound signing secret inside the connection's encrypted credentials blob (alongside the consumer key/secret). Shown to the clinic admin once, at create/rotate.
    • A small resolver: token → organization_integrations row → decrypt → (inbound_secret, consumer_creds, store_url).
  • [ ] Per-provider inbound packages internal/integration/{woocommerce,merchantpro}/inbound/:
    • verify.goVerify(req, secret): base64 HMAC-SHA256 of the raw body, crypto/subtle.ConstantTimeCompare against X-WC-Webhook-Signature (Woo) / X-Webhook-Signature (MerchantPro).
    • parse.goParse(body) (Event, error); Event.ID is the dedup event_id (Woo delivery/webhook id or order id; MerchantPro event id).
    • handler.go — the locked flow: resolve token → decrypt secret → Verify → dedup WasProcessed (early 200 on replay) → pull authoritative order via Cat B → gate on paid → emit commerce.order_paidMarkProcessed.
  • [ ] Mount endpoints under the existing /webhooks group (per-IP rate-limited, no auth, sibling of /v1): POST /webhooks/woocommerce/{token} and POST /webhooks/merchantpro/{token}. Add an inbound_webhook rate-limit policy if not already present.
  • [ ] Register the Cat E event commerce.order_paid in the 1C.3 events registry, emitted at the end of the flow. Payload carries organization_id, integration_id, order_ref, and the normalized line items — the contract the provisioning slice consumes.
  • [ ] Dedup uses the existing inbound_webhook_dedup table (monthly-partitioned, AdminPool-only). Idempotency boundary is (provider, event_id). Retry is the shop's job (5xx → re-POST → dedup short-circuits).

Track C — Clinic UI (configure the connection)

No integrations surface exists in the Clinic app today (1D.2 L19, "not-built"). The Cat B API and packages/api-client methods (listIntegrationServices, createIntegration, testIntegration, …) already exist — this track is the missing frontend.

  • [ ] /integrations route in apps/clinic/app/(dashboard)/ — list connected shops (service, status, last-used, error state) + "Connect shop" flow. Mirror the shipped SWR pattern in components/patient-tiers/patient-tiers-editor.tsx; gate on organizations.manage_integrations (sidebar entry already stubbed).
  • [ ] Connect dialog — pick provider from the catalog → credential form (consumer_key, consumer_secret, store_url) → create → run /test.
  • [ ] Write-only secret input (net-new shared pattern; none exists) — masked, never echoed back, with a "replace credentials" affordance. Note: the Cat B API has no credential-rotation endpoint yet (PATCH is title/config only) — rotation is revoke + recreate unless we add it; flag for the guide.
  • [ ] Inbound webhook setup panel (net-new shared pattern; none exists) — display the per-connection webhook URL + signing secret with copy-to-clipboard, a "regenerate" control, and step-by-step "paste this into your WooCommerce/MerchantPro webhook settings" instructions.

Compliance & security

  • Credentials (auth_secret class): per-org, KMS-encrypted, never returned on read, redacted in audit/logs. Consumer key/secret + the generated inbound secret all live in credentials_encrypted.
  • Signature verification is mandatory before any action; failed verification → reject + audit. Raw body is captured for HMAC.
  • Idempotency: exactly-once via the dedup table; replays are no-ops.
  • Verify-by-pull: provisioning never trusts a pushed payload's money fields — it re-reads the authoritative order.
  • PII minimization & egress: order billing PII flows shop→clinic; clinic is controller, platform is processor. Persist only what identity-linking/profile needs. Every new column (inbound_url_token, any order/fulfillment record fields) needs a data-classification.md registry entry in the same PR (CI enforces). inbound_url_token is a routing bearer token — classify accordingly (not egressable beyond the clinic-admin surface that displays it).
  • SOUP: prefer stdlib HMAC (no new dep). Any shop SDK added → SOUP row in the same PR.
  • RLS: organization_integrations is already per-org RLS-gated on organizations.manage_integrations. The inbound route is unauthenticated by necessity (the shop has no session) — its security is the unguessable token + HMAC, not RLS.

Follow-ups (out of scope here)

  • Provisioning slice — consume commerce.order_paid: per-org SKU→offer mapping, the shared provisioner minting patient_content_grants / patient_subscriptions, and the post-checkout onboarding page (buyer signs up via Clerk + consents, provisioning completes). This is where "one core, two triggers" converges with the marketing-campaign trigger.
  • Refund → revoke — SHIPPED (F14.5): commerce.order_refunded lapses content grants precisely (via patient_content_grants.fulfillment_id). Auto tier-revoke stays deferred (replace-semantics ambiguous).
  • Reconciliation sweep — periodic pull to catch orders whose webhook was missed (the redirect+pull onboarding spine is already self-healing for buyers who show up; this covers the rest). Still deferred.
  • Credential-rotation endpoint for Cat B (PATCH currently can't rotate secrets). Still deferred.
  • Registered as F14. Commerce & Access Offers in the implementation plan (2026-06-01).