Recurring revenue where the platform forbids it

B2B Subscriptions Engine

Shopify B2B checkout rejects subscriptions. This ships them anyway.

The problem

Shopify B2B checkout rejects any cart line carrying a selling plan.

Country Life Natural Foods sells wholesale to companies that reorder the same pallets on a fixed cadence — exactly the customers subscriptions exist for. But a Shopify company checkout refuses any cart line carrying a selling plan and returns "only one-time purchase is available". The native subscription path is unavailable for the precise segment that needs it, and no app can turn it on, because the restriction lives in checkout itself. Staff were re-keying standing orders by hand every cycle. The work was to build the subscription layer outside the part of the platform that forbids it, without asking a buyer to do anything unusual at checkout.

Prisma models
12Prisma models
GraphQL operations
44GraphQL operations
application routes
32application routes
webhook handlers
12webhook handlers
unit tests
49unit tests
Shopify extensions
4Shopify extensions

How it works

The bridge is the whole idea. Because a B2B cart cannot carry a selling plan, the storefront submits an ordinary one-time line and smuggles the buyer's choice through line-item properties — two named keys pinned in a shared constants module and mirrored in the theme block and the checkout Function. The orders/create webhook reads those properties back, fetches the genuine billing and delivery policies from the referenced selling plan, and synthesises a contract through subscriptionContractAtomicCreate. That mutation produces a real Shopify subscription contract, visible in Admin and manageable by the customer, without a selling plan ever touching the cart. The second consequence is larger than the first: Shopify only schedules renewals for contracts it created itself, so an atomically-created contract is inert. The app therefore owns its own renewal loop — a daily Vercel cron resolves each due contract's billing cycle and issues a billing attempt. Local Postgres mirrors contract state because subscription webhooks omit priced line items, so a separate Admin API fetch syncs lines after every event. Customer self-service runs through an App Proxy rather than the app URL directly, and the customer-account extension shares its handler verbatim with the direct route.

System architecture diagram: storefront theme extension to Shopify Function to orders/create webhook to Postgres to Vercel cron to billing and notifications, with App Proxy path for customer account extension

System diagram: the bridge that reconstructs the contract outside checkout, plus the renewal loop the platform will not run.

The buyer-facing surface

Wholesale buyers manage their own subscriptions from a full-page customer-account UI extension — list, pause, resume, cancel, or change cadence to a shared preset — with teaser blocks on the profile and order-index pages linking in. Two constraints shaped the design: Transport — the extension can only fetch from the shop domain (CSP connect-src), so all traffic goes through a Shopify App Proxy that signs and forwards requests to the app. The same handler serves the proxy route and the direct route verbatim. Scope — anything Shopify lets the buyer edit directly is applied immediately via contract mutations. Anything else — a custom next-billing date, an off-preset cadence — is recorded as a support-pending flag, staff are notified by email and Slack, and the flag auto-clears when Shopify's state converges with the request. Only buyers carrying the Wholesale customer tag see any of it — the same gate enforced by the storefront block and the checkout Function, so the rule lives in three places and fails closed.

Engineering decisions

The non-obvious calls — where the straightforward approach fails, and what it cost to find that out.

01

The selling plan input must be empty, not disabled

Why it breaks

The obvious way to turn off a form field is to disable it. Doing that here silently re-breaks B2B checkout: a disabled input is omitted from the form body entirely, and Shopify then falls back to a default selling plan allocation for the variant — which is exactly the subscription line the company checkout refuses.

What I did

The field stays enabled and is set to an empty string, so the cart receives an explicit "no selling plan" rather than no answer at all. It is a one-character difference between a working wholesale checkout and an error the buyer cannot get past, and nothing about the symptom points at the cause.

02

Every contract is anchored to 11:00 UTC

Why it breaks

Vercel's Hobby tier permits one cron run per day. A contract whose next billing date lands at 18:00 UTC is not yet due when the 11:00 job looks at it, so it is skipped — and picked up a day late on the next run. That slip compounds every single cycle, so a weekly subscription drifts a full day per week until it has wandered off its intended schedule entirely.

What I did

Contracts created by the bridge snap their next billing date forward to the first 11:00 UTC instant at or after the policy minimum, whatever the cadence. The anchor is a named constant that documents its coupling to the cron schedule, because the two are a single decision recorded in two files.

/**
 * Aligns B2B subscription renewals with Vercel cron (`vercel.json`: `0 11 * * *`).
 * Keep these in sync when changing the cron schedule.
 */
export const B2B_INVOICE_RENEWAL_ANCHOR_HOUR_UTC = 11;
export const B2B_INVOICE_RENEWAL_ANCHOR_MINUTE_UTC = 0;
app/lib/b2b-subscription-billing-anchor.server.ts
03

Billing attempts need an explicit origin time

Why it breaks

subscriptionBillingAttemptCreate does not infer the billing origin the way the cycle-charge mutation does. Omit originTime and Shopify may use the current moment, which can fall outside a past-due cycle's window — the charge is then rejected for an origin time outside the cycle, on a contract that is legitimately overdue and should bill.

What I did

The cron resolves the cycle first, then passes that cycle's expected date as the origin. The idempotency key is derived from the same instant, so a retry of the same cycle collapses onto the original attempt instead of double-charging a wholesale account.

04

Resource routes must export no component and no ErrorBoundary

Why it breaks

React Router only takes the raw resource-route path — JSON in, JSON out — when a route exports neither a default component nor an ErrorBoundary. Adding an ErrorBoundary, which is otherwise good practice, silently forces the full HTML document pipeline. The failure is genuinely nasty: the action still runs and its side effects still happen, but the extension's fetch receives a streamed HTML document instead of JSON and parses it as garbage.

What I did

Both customer-account endpoints carry a comment stating the constraint and why, because the next person to add an error boundary would be doing the right thing everywhere else in the codebase.

05

Clearing a metafield needs delete, not an empty set

Why it breaks

The theme reads a shop metafield listing which selling plan groups to hide. Writing an empty string to clear it fails — Shopify rejects a blank value for a single-line text field. The bug only appears when un-hiding the last hidden group, so a staff member who hides two groups and un-hides both finds the second one permanently stuck, with no error surfaced in the UI.

What I did

Clearing the list deletes the metafield instead of setting it empty, and the surrounding function reverts its own database write if the Shopify sync fails, reporting that it did so rather than leaving the two stores disagreeing.

06

A patch to React Router itself

Why it breaks

Customer-account extensions post from a Shopify CDN origin, which triggers a CORS preflight. React Router's static handler rejects any method outside its known set before routing — including OPTIONS — so every preflight was refused by the framework and the entire customer-account surface was unreachable.

What I did

A patch, applied through patch-package on postinstall, allows OPTIONS through to the route. Vendoring a framework patch is a real maintenance cost and not a first resort; it is recorded as a deliberate trade against having no customer self-service at all.

07

Pin the Admin API a version behind, on purpose

Why it breaks

Shopify added an invoice-style renewal policy (SKIP_PAYMENT_AND_CREATE_UNPAID_ORDER) to subscriptionBillingAttemptCreate in Admin API 2026-04. Adopting it looks like an upgrade — but the app's webhooks are pinned to 2025-07, and passing the new argument there fails at runtime with "isn't a defined input type". Bumping the pin means re-testing every mutation in the app to chase one feature the vaulted-card model does not need.

What I did

Stay on 2025-07 and omit the argument, relying on the default FAIL_UNLESS_VALID_PAYMENT_METHOD policy — correct for vaulted-card B2B contracts. A regression test stands up a fake Shopify GraphQL server and asserts the field is absent from the payload, so a future contributor cannot reintroduce it from newer documentation.

08

Idempotency keys derived from the cycle, not the clock

Why it breaks

Shopify's @idempotent GraphQL directive is silently ignored on subscription mutations — it is only registered for inventory and refund mutations. Relying on it means a retried cron run can double-charge a wholesale account. And a naive key (contract + date) breaks when a retry happens across a UTC midnight boundary.

What I did

Before charging, the cron resolves the cycle through subscriptionBillingCycle to read its cycleIndex and billingAttemptExpectedDate, then builds the idempotency key from that expected instant — clnf-b2b-{contractGid}-{billingAttemptExpectedDate}. Retries within Shopify's 24-hour window collapse onto the original attempt, and the originTime passed with the attempt is the same instant, which also fixes the "origin time needs to be within the cycle" rejection on past-due contracts.

const cycle = await shopify.subscriptionBillingCycle({
  contractId: contract.gid,
  // ...
});
const idempotencyKey = `clnf-b2b-${contract.gid}-${cycle.billingAttemptExpectedDate}`;
// Pass both the key and cycle.billingAttemptExpectedDate as originTime
await shopify.subscriptionBillingAttemptCreate({
  // ...
  idempotencyKey,
  originTime: cycle.billingAttemptExpectedDate,
});
Idempotency key construction in the renewal cron

Correctness in production

What keeps it right once nobody is watching it run.

Webhook dedup as a race-free mutex

Shopify retries a failed webhook up to 19 times, so every handler must be idempotent. The obvious implementation — look up the ID, then insert if absent — has a race: two concurrent redeliveries both find nothing and both process. Instead the insert is attempted first and a unique-constraint violation is caught as proof another delivery won. The database provides the mutual exclusion, so concurrency correctness does not depend on timing.

Six independent idempotency layers

Webhook delivery, contract creation, billing attempt, billing receipt, notification send and managed-contract registration each have their own uniqueness guarantee. They overlap deliberately: any one of them failing open is caught by the next, which is what allows the system to retry aggressively without risk of double-billing a wholesale customer.

Per-channel notification dedup

A billing failure alerts Slack and emails the customer. These are tracked as separate delivery timestamps on one record rather than a single sent flag, so if Slack succeeds and the email provider is down, the retry sends only the email. A cron sweeps undelivered legs from the previous 48 hours.

Re-validation before the irreversible step

A third-party order importer once produced orders that passed the initial eligibility check without being genuinely B2B. Creating a subscription contract is not cheaply reversible, so eligibility is now re-confirmed immediately before the create call — accepting a redundant API round trip to close a race that already happened once in production.

Diagnostics designed for the person on call

Each cron run persists its shop count, attempt count and truncated errors, and the admin page reads the latest row to answer "did the job actually run?" — which is how a missing environment variable gets noticed without shell access. Slack alerts carry Shopify Admin deep links rather than raw identifiers, and error responses name the remedy, not just the fault.

Three tiers of automated testing

Unit tests stand up a fake Shopify GraphQL server and assert the exact billing-attempt payload — including a regression test that a field unavailable before Admin API 2026-04 is absent, so a future contributor cannot reintroduce it from newer documentation. Integration tests run against a real Postgres container with per-test truncation, and the checkout Function is compiled to Wasm and executed against fixtures rather than mocked.

Privacy and teardown are part of the build

The mandatory privacy webhooks (customers/data_request, customers/redact, shop/redact) are registered through Shopify's compliance_topics syntax — not topics, a distinction Shopify enforces by silently dropping mis-typed subscriptions — and dispatched from a single unified route. app/uninstalled scrubs the shop's sessions, mirrored contracts, billing rows, and settings from Postgres. Cron routes require a bearer secret; the admin-only manual renewal path reuses the same code authenticated by the admin session instead.

Outcome

Wholesale buyers check out with a cadence the same way they check out without one — nothing unusual at the register. Contracts materialize server-side seconds later, renewals charge the vaulted card daily without staff involvement, and failed payments alert staff on Slack and email the customer automatically, each exactly once. The standing-order re-keying the system replaced is gone, and the support surface (cadence changes, billing-date requests) lands in the app's own Admin pages instead of an inbox. Order replay exists for the one failure mode that cannot be retried blindly: when a payment method had not vaulted at order time, staff re-run the bridge for that order from Admin.

Ready to upgrade your store's infrastructure?

Whether you want this exact architecture deployed to your store, or a completely bespoke solution built from scratch.

I want this exact solution.

Skip the R&D. I can deploy the B2B Subscriptions Engine architecture directly to your operations in days, not months.

I need something custom.

Have a unique workflow? Let's build a bespoke app tailored perfectly to your complex business needs.