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.
The Stack That Looked Fine on Paper
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.

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.
Two Failures That Broke Every B2B Order
Failure 1: The Company–Customer Mismatch
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.
Failure 2: The Order Prefix Problem
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.
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.
Why We Couldn’t Fix It Inside the Existing Systems
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.
Evaluating the Options
Option A — Customise the ERP or OMS
Blocked. And even if unblocked, coupling critical integration logic to a vendor’s release cycle is an architectural liability that compounds with every upgrade.
Option B — Build a Hosted Middleware Service
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.
Option C — Serverless Edge Middleware
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.
Why Cloudflare Workers Won

Cold Starts Are Architecturally Fatal Here
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.
Billing for CPU, Not Waiting
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.
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.
The Architecture: Full Request Lifecycle

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.
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.
Security: Four Independent Layers

Layer 1 — Cloudflare’s Network Perimeter
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.
Layer 2 — Inbound HMAC Verification
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.
Layer 3 — Re-Signing as Identity Proxy
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.
Layer 4 — Secret Management and Replay Prevention
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.
The Transformation Logic

Detecting B2B
const order = JSON.parse(rawBody);
const isB2B = order.company !== null && order.company !== undefined;
if (!isB2B) {
// DTC order: forward unchanged
return forwardToOMS(rawBody, originalHeaders);
}
GraphQL Enrichment
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 }
}
}
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.
Prefix Resolution and Rewrite
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.
Reliability: Idempotency, Retries, and Observability

Idempotency
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.
Retry with Jitter
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.
Structured Logging
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?) |
Trade-offs: What This Architecture Does and Doesn’t Do
It Does Well
- 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.
Its Limits
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.
When to Use Workers vs an iPaaS
| 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 |
Outcome
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
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.