The 150-Line Fix: How a Cloudflare Workers Middleware Resolved Our Shopify B2B Integration Deadlock

Some integration problems don’t announce themselves loudly.

They surface as a quiet wrongness — orders arriving without context, financial routing failing silently, customer accounts misidentified. You only notice when the downstream chaos is already deep.

Ours started the moment we switched on Shopify B2B.

The symptom was simple: every wholesale order was being treated as a regular consumer order by our ERP. But the root cause was a three-way architectural collision between systems that each refused to move.

What follows is the full story — the problem, the constraints, the decision, and exactly how a 150-line TypeScript function at Cloudflare’s edge fixed what three vendor teams couldn’t.


Our commerce architecture followed a pattern most mid-market retailers would recognise:

  • Shopify handles the storefront and order capture
  • OMS (owned by a separate partner) orchestrates fulfilment and routes orders
  • ERP (a closed-source legacy monolith) runs financials, inventory, and business automations

Orders flow: Shopify → OMS → ERP.

Clean. Logical. Until Shopify B2B entered the picture.

Diagram showing Shopify B2B order flowing to OMS and ERP with two failure points: company-customer mismatch and missing order prefix
Three systems, two gaps, zero flexibility — the integration deadlock
Important

The failure wasn’t any single system being broken. It was three systems being correct for their own purposes — and completely incompatible with each other’s expectations.


Shopify B2B attaches two entities to every wholesale order: a Company (the business account) and a Customer (the individual who placed it).

Our ERP identifies accounts by company, not by individual. Without the company identity, it had no idea which business the order belonged to — and routed everything as a consumer transaction.

The standard Shopify → OMS connector passed only the customer object. The company context was silently dropped.

Our ERP doesn’t route orders by looking up a company ID. It reads the prefix of the order number.

ACME-1042 routes to the ACME Corp business unit, GL account, fulfilment workflow, and tax logic. BETA-1042 routes to a completely different set. The prefix is not cosmetic — it is the ERP’s entire routing key.

Shopify applies one global prefix per store. There is no per-company, per-channel, or conditional prefix setting. The OMS connector had no mechanism to inject one. The ERP had no fallback logic for an absent prefix.

Every B2B order entered a manual exception queue.

Warning

In a B2B environment with bulk orders, net-30 payment terms, and multi-location company hierarchies, a manual exception queue isn’t an inconvenience. It’s a financial control failure.


The ERP is closed source. No extension points, no plugin architecture, no published API for dynamic order number parsing. A change request to the vendor had a 6–12 month roadmap outlook, and no guarantee of fit.

The OMS is a managed SaaS product owned by a separate partner, built for horizontal compatibility across thousands of merchants. Supporting per-company prefix injection was outside its product scope. The partner was willing to explore it — with a custom build timeline and cost that made no business sense.

Shopify’s native order numbering is a store-wide global setting. No Flow automation, no metafield trick, and no app can dynamically rewrite the order.name field at creation time based on which company placed the order.

The data needed to be reshaped. None of the three systems could reshape it. The transformation had to happen somewhere between them.


Blocked. And even if unblocked, coupling critical integration logic to a vendor’s release cycle is an architectural liability that compounds with every upgrade.

The conventional approach: a Node.js or Go service, containerised, running on ECS or Kubernetes, with its own CI/CD pipeline, load balancer, retry workers, dead-letter queues, and monitoring stack.

For complex, stateful integrations, this is correct. For our use case, it is severe overkill. The transformation logic is deterministic and stateless: receive order → look up prefix by company → rewrite order name → forward payload. Running permanent container infrastructure for a JSON transform function that fires on order creation means maintaining that infrastructure indefinitely — patching, scaling, on-call coverage — for a function that executes in under 10ms of CPU time.

The requirements mapped precisely:

  • Stateless request/response
  • Milliseconds of CPU computation
  • Zero cold-start tolerance (Shopify has a 5-second webhook timeout)
  • No persistent state to manage
  • Negligible operational overhead

We chose Cloudflare Workers.


Side-by-side technical comparison diagram: AWS Lambda boot sequence with cold start penalty vs Cloudflare Workers V8 isolate instantaneous spin-up
V8 Isolates vs Firecracker MicroVMs — the architecture that determines cold start behaviour

Shopify enforces a strict five-second response timeout for webhooks. Exceed it and Shopify logs a failure, begins exponential backoff retries, and eventually deletes the webhook subscription entirely — silently severing the Shopify-to-ERP connection.

AWS Lambda runs on Firecracker microVMs. Cold starts range from 800ms to over 2,300ms — a dangerous fraction of that five-second window, leaving almost no margin for HMAC verification, a GraphQL call, and payload transformation.

Cloudflare Workers use Chrome V8 isolates. Instead of booting a virtual machine per invocation, Workers run as isolated memory contexts within an already-running V8 engine at each edge node. Startup time: under 5ms. Cloudflare’s “shard-and-conquer” consistent hashing keeps isolates perpetually warm, achieving a reported 99.99% warm request rate.

A webhook middleware is overwhelmingly I/O wait, not computation:

  • 2–5ms — HMAC verification
  • 150–400ms — GraphQL call to Shopify (network I/O — billed as zero on Workers)
  • 1–3ms — JSON transform and prefix rewrite
  • 50–200ms — Forward to OMS (network I/O — billed as zero on Workers)

Lambda charges for the full 200–600ms wall-clock duration. Workers charges for the 5–10ms of actual CPU cycles. At our order volumes, the monthly cost on Workers is under $10.

Note

Workers pricing: $5/month base, includes 10 million requests and 30 million CPU milliseconds. Egress is free. The entire middleware costs less than a lunch order.


Horizontal data flow diagram showing 9 labelled stages: Shopify order creation, Cloudflare edge TLS termination, Worker HMAC verify, GraphQL enrichment, payload transform, re-signing, 200 ACK to Shopify, OMS forward, ERP routing
Complete order lifecycle — from Shopify webhook to ERP routing, across 9 steps

Here is exactly what happens on every B2B order creation event:

Step 1 — Shopify fires the webhook orders/create dispatched to the Worker’s custom domain endpoint.

Step 2 — Cloudflare edge TLS 1.3 termination at the nearest of 330+ global edge nodes. Cloudflare WAF, DDoS protection, and bot-fight mode evaluate the request before the Worker code runs. Most adversarial traffic is dropped here at zero CPU cost.

Step 3 — HMAC verification Worker reads the raw request body as bytes (no JSON parsing yet — critical). Computes HMAC-SHA256 against Shopify’s shared secret using the Web Crypto API’s crypto.subtle.verify() — which performs constant-time comparison internally, preventing timing-side-channel attacks. Any mismatch returns 401 immediately.

Step 4 — B2B detection and GraphQL enrichment If order.company is non-null, the order is B2B. The Worker calls Shopify’s GraphQL Admin API to retrieve the company’s externalId, name, location, and purchase order number — data the REST webhook payload omits entirely.

Step 5 — Prefix resolution and payload transformation The company’s externalId maps to the correct ERP prefix. order.name and order_number are rewritten. Company identity is remapped to the field the OMS routes by. PO number is injected into note_attributes.

Step 6 — Re-signing The mutated payload is re-signed with the OMS’s shared secret. A fresh X-Shopify-Hmac-SHA256 header is generated. The OMS receives a correctly signed payload and never detects the transformation.

Step 7 — 200 ACK to Shopify ctx.waitUntil() decouples the response from the forwarding. Shopify receives its 200 in under 50ms. The OMS forward continues asynchronously.

Step 8 — OMS processes Receives properly mapped company identity, correct order prefix, complete B2B context. Passes to ERP.

Step 9 — ERP routes correctly Reads the prefix. Routes to the correct business unit, GL account, and fulfilment workflow. No exception queue. No manual intervention.

Important

ctx.waitUntil() is the architectural key. It allows the Worker to return 200 to Shopify immediately — satisfying the five-second timeout — while the enrichment, transformation, and OMS forwarding continue asynchronously in the background.


Layered security architecture diagram showing 4 concentric rings: Cloudflare network perimeter (DDoS, WAF, bot-fight), custom domain obscurity, inbound HMAC-SHA256 verification, and outbound re-signing with OMS credential
Defence in depth — four independent security layers before payload processing begins

Every request hits Cloudflare’s edge before reaching Worker code. DDoS mitigation, bot scoring, and WAF rules drop adversarial traffic without consuming CPU. The endpoint is bound to a custom, non-obvious subdomain — not the default workers.dev domain.

Shopify signs every webhook with HMAC-SHA256. The Worker verifies this using crypto.subtle — the Web Crypto API built into the V8 isolate runtime. Critical implementation detail: the raw body must be read as bytes before any JSON parsing. Parsing and re-serialising even losslessly changes the byte sequence and invalidates the signature.

crypto.subtle.verify() performs constant-time comparison internally. Timing attacks — where an attacker measures comparison duration to brute-force the signature — are structurally prevented.

After transformation, the payload is re-signed with the OMS’s secret. The OMS validates this signature and sees a trusted Shopify origin. Neither the OMS nor the ERP knows the payload was transformed in flight.

All secrets (Shopify client secret, OMS credential, Shopify access token) are stored via wrangler secret put — encrypted at rest, injected at invocation, never in source code. Replay attacks are blocked by enforcing a 10-minute timestamp window using X-Shopify-Triggered-At. Duplicate deliveries are de-duplicated via X-Shopify-Event-Id stored in Workers KV with a 4-hour TTL.


Split-panel diagram showing raw Shopify B2B webhook JSON on the left (company.id only, no prefix, flat customer mapping) vs transformed OMS-bound payload on the right (company externalId, ACME- prefix on order name, correct account field mapping, PO number in note_attributes)
Inside the transformation — what changes between the Shopify webhook and the OMS-bound payload
const order = JSON.parse(rawBody);
const isB2B = order.company !== null && order.company !== undefined;

if (!isB2B) {
  // DTC order: forward unchanged
  return forwardToOMS(rawBody, originalHeaders);
}

The REST webhook’s company object contains only id and location_id. To get the ERP-usable data — company name, externalId, PO number, payment terms — the Worker calls Shopify’s GraphQL Admin API using the purchasingEntity field:

query GetOrderCompany($id: ID!) {
  order(id: $id) {
    purchasingEntity {
      ... on PurchasingCompany {
        company { id name externalId }
        location { id name }
        contact { customer { firstName lastName email } }
      }
    }
    poNumber
    paymentTerms { paymentTermsType dueInDays }
  }
}
Note

As of April 2025, all new Shopify apps must use the GraphQL Admin API. The legacy REST API does not expose Company or B2B endpoints natively. Any middleware that needs to hydrate company data from a webhook must speak GraphQL.

const PREFIX_MAP = {
  'ACME_CORP':   'ACME-',
  'BETA_DIST':   'BETA-',
  'GAMMA_WHSL':  'GAMMA-',
  // new companies added here, or via Workers KV for dynamic config
};

const prefix = PREFIX_MAP[companyExternalId] ?? 'B2B-'; // fallback

order.name = prefix + order.name;          // e.g. ACME-#1042
order.order_number = prefix + order.order_number;

All other order data — line items, pricing, tax lines, shipping address, payment information — is preserved exactly as Shopify sent it.


Mock observability dashboard showing structured JSON log entry fields: event type, orderId, companyExternalId, prefixApplied, omsResponseStatus, totalLatencyMs, graphqlLatencyMs, retryAttempts, timestamp — alongside a latency distribution chart and success rate gauge
Structured per-event telemetry — what every processed order emits to the observability stack

Shopify delivers webhooks at-least-once. Duplicate deliveries are deduped via X-Shopify-Event-Id stored in Workers KV with a 4-hour TTL. A previously seen event ID gets a 200 response and no reprocessing.

OMS forward failures trigger exponential backoff with random jitter inside ctx.waitUntil():

const delay = Math.min(1000 * 2 ** attempt, 8000) + Math.random() * 500;

Shopify’s own retry mechanism (8 retries over 4 hours) acts as the outer safety net if the Worker’s retry budget is exhausted.

Every execution emits a structured JSON log:

{
  "event": "order.transformed",
  "orderId": "5678901234567",
  "shopifyEventId": "abc123def456",
  "companyExternalId": "ACME_CORP",
  "prefixApplied": "ACME-",
  "omsResponseStatus": 200,
  "totalLatencyMs": 312,
  "graphqlLatencyMs": 198,
  "retryAttempts": 0,
  "timestamp": "2026-03-06T14:22:01Z"
}

Logs flow to Cloudflare Workers Logs natively, and to an external platform (Axiom) via Logpush over OpenTelemetry-compatible OTLP export. Any HMAC failure rate above 1% triggers an alert — a signal for potential spoofing or a rotated Shopify secret.

Metric Target Alert Threshold
Webhook ACK latency < 200ms P95 > 1,000ms P95
HMAC verification failure rate < 0.1% > 1%
OMS forward success rate > 99.9% < 99% over 15 min
Dead-letter events 0 Any
Unknown company IDs 0 Any (new company?)

  • Operational overhead is negligible. The Worker deploys in seconds. No servers, no containers, no auto-scaling policies.
  • Latency impact is sub-50ms. The transformation adds under 5ms of CPU to the request path.
  • Security is layered independently. Each of the four defence layers functions without the others.
  • Cost is effectively zero. Under $10/month for a critical integration path serving the entire wholesale channel.
  • The source is auditable. The full Worker is under 300 lines of TypeScript. Any developer on the team can read and modify it in an afternoon.
Caution

There is no persistent dead-letter queue. If the Worker’s retry budget is exhausted and the isolate terminates, the event is lost unless Shopify’s own retry fires. For stricter durability requirements, replace ctx.waitUntil() with Cloudflare Queues.

  • The prefix lookup table is baked into the Worker. Adding a new B2B company requires a redeployment (or a Workers KV migration for non-developer-managed config).
  • The GraphQL enrichment call adds a network dependency on Shopify’s Admin API. Degraded Shopify APIs introduce processing delays.
  • This pattern is not designed for multi-system orchestration, conditional branching with external state, or protocols beyond HTTP/REST.
Workers is right when… Consider an iPaaS when…
1–3 downstream systems over REST/JSON 5+ interconnected systems with branching logic
Transformations are stateless and deterministic Orchestration requires distributed state or rollback
A developer can own and maintain the code Non-engineers need to manage integration flows
Volume is under ~10M events/month Compliance requires SOC 2 audit trails per event
Downstream systems speak HTTP/REST ERPs require SOAP, EDI/AS2, or SAP RFC

A three-system integration deadlock — blocked across three separate vendor teams for weeks — resolved by a 300-line TypeScript function deployed in two days.

Results since deployment:

  • B2B company identity correctly mapped in every order the OMS and ERP receive
  • Every wholesale company’s orders arrive with the correct ERP routing prefix — zero manual exceptions
  • Shopify receives its 200 ACK in under 50ms — webhook subscription has never been dropped
  • Monthly infrastructure cost: under $10
  • No server, no container, no infrastructure to maintain
Important

The deeper lesson is about where integration logic belongs in a constrained stack. When both the upstream and downstream systems are rigid — one a modern cloud platform, one a legacy monolith, with a standardised connector in between that can’t be customised — the only viable intervention point is the space between them. Serverless edge compute makes that intervention point cheap to build, fast to run, and simple to maintain.

It is not a full middleware platform.

It does not need to be.

For a single, well-defined, stateless transformation on a predictable data shape, it is exactly the right tool.


Are you dealing with a similar ERP or OMS integration constraint? Or have you found a different pattern for Shopify B2B company mapping? I’d love to hear your approach in the comments.