<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[PuraSIM Engineering]]></title><description><![CDATA[Learn how to build an idempotent Shopify webhook workflow using Cloudflare Workers and D1 to prevent duplicate digital product provisioning.]]></description><link>https://purasim.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>PuraSIM Engineering</title><link>https://purasim.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 15 Oct 2026 11:09:04 GMT</lastBuildDate><atom:link href="https://purasim.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How to Prevent Duplicate Digital Product Fulfillment with Shopify Webhooks and Cloudflare Workers]]></title><description><![CDATA[When a Shopify store automatically delivers a digital product after payment, processing the same webhook twice can become expensive.
This is especially important for products that must be provisioned ]]></description><link>https://purasim.hashnode.dev/how-to-prevent-duplicate-digital-product-fulfillment-with-shopify-webhooks-and-cloudflare-workers</link><guid isPermaLink="true">https://purasim.hashnode.dev/how-to-prevent-duplicate-digital-product-fulfillment-with-shopify-webhooks-and-cloudflare-workers</guid><category><![CDATA[shopify]]></category><category><![CDATA[cloudflare]]></category><category><![CDATA[webhooks]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[ecommerce]]></category><dc:creator><![CDATA[Marc González]]></dc:creator><pubDate>Sun, 02 Aug 2026 22:04:53 GMT</pubDate><content:encoded><![CDATA[<p>When a Shopify store automatically delivers a digital product after payment, processing the same webhook twice can become expensive.</p>
<p>This is especially important for products that must be provisioned through an external API, such as software licences, gift cards, downloadable assets or travel eSIMs.</p>
<p>A typical workflow looks like this:</p>
<ol>
<li><p>Shopify sends an <code>orders/paid</code> webhook.</p>
</li>
<li><p>A server receives the order.</p>
</li>
<li><p>The server calls an external provider API.</p>
</li>
<li><p>The digital product is created.</p>
</li>
<li><p>The customer receives the product by email.</p>
</li>
</ol>
<p>The dangerous assumption is that the webhook will only be processed once.</p>
<h2>The duplicate provisioning problem</h2>
<p>Imagine the external provider successfully creates the product, but the response times out before our server receives it.</p>
<p>Our application may return an error to Shopify. The webhook is delivered again, and the second execution creates another product for the same order.</p>
<p>The customer may receive two products, while the merchant pays the supplier twice.</p>
<p>To prevent this, the workflow needs to be idempotent.</p>
<h2>Creating a stable provisioning key</h2>
<p>The Shopify webhook ID is useful for detecting repeated webhook deliveries, but the safest business-level key should also represent the resource being created.</p>
<p>For example:</p>
<pre><code class="language-javascript">const provisioningKey = `${order.id}:${lineItem.id}:${unitNumber}`;
</code></pre>
<p>This means that each unit of each line item has its own permanent provisioning identifier.</p>
<p>If an order contains three digital products, the workflow creates three different keys:</p>
<pre><code class="language-text">ORDER_ID:LINE_ITEM_ID:1
ORDER_ID:LINE_ITEM_ID:2
ORDER_ID:LINE_ITEM_ID:3
</code></pre>
<h2>Why a simple KV lookup may not be enough</h2>
<p>A basic implementation might first check whether a key exists and then save it:</p>
<pre><code class="language-javascript">const existing = await env.PROCESSED_JOBS.get(provisioningKey);

if (existing) {
  return new Response("Already processed", { status: 200 });
}

await env.PROCESSED_JOBS.put(provisioningKey, "processing");
</code></pre>
<p>The problem is concurrency.</p>
<p>Two Worker executions could read the missing value at approximately the same time. Both executions would then continue and call the external provider.</p>
<p>For operations that must happen exactly once, the reservation should be atomic.</p>
<h2>Reserving the job with Cloudflare D1</h2>
<p>A D1 table can use the provisioning key as its primary key:</p>
<pre><code class="language-sql">CREATE TABLE provisioning_jobs (
  provisioning_key TEXT PRIMARY KEY,
  shopify_order_id TEXT NOT NULL,
  shopify_line_item_id TEXT NOT NULL,
  status TEXT NOT NULL,
  provider_reference TEXT,
  error_message TEXT,
  created_at TEXT DEFAULT CURRENT_TIMESTAMP,
  updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
</code></pre>
<p>The Worker can attempt to insert the job before calling the provider:</p>
<pre><code class="language-javascript">const result = await env.DB.prepare(`
  INSERT INTO provisioning_jobs (
    provisioning_key,
    shopify_order_id,
    shopify_line_item_id,
    status
  )
  VALUES (?, ?, ?, 'pending')
  ON CONFLICT(provisioning_key) DO NOTHING
`)
  .bind(
    provisioningKey,
    String(order.id),
    String(lineItem.id)
  )
  .run();

if (result.meta.changes === 0) {
  return new Response("Job already exists", { status: 200 });
}
</code></pre>
<p>Only one execution should successfully insert the key. Other executions see that the job already exists and stop before provisioning another product.</p>
<h2>Separate webhook reception from provisioning</h2>
<p>The webhook handler should remain small and fast:</p>
<pre><code class="language-javascript">export default {
  async fetch(request, env) {
    if (request.method !== "POST") {
      return new Response("Method not allowed", { status: 405 });
    }

    const order = await request.json();

    for (const lineItem of order.line_items ?? []) {
      for (let unit = 1; unit &lt;= lineItem.quantity; unit++) {
        const provisioningKey =
          `${order.id}:${lineItem.id}:${unit}`;

        const result = await env.DB.prepare(`
          INSERT INTO provisioning_jobs (
            provisioning_key,
            shopify_order_id,
            shopify_line_item_id,
            status
          )
          VALUES (?, ?, ?, 'pending')
          ON CONFLICT(provisioning_key) DO NOTHING
        `)
          .bind(
            provisioningKey,
            String(order.id),
            String(lineItem.id)
          )
          .run();

        if (result.meta.changes &gt; 0) {
          await env.PROVISIONING_QUEUE.send({
            provisioningKey,
            orderId: String(order.id),
            lineItemId: String(lineItem.id)
          });
        }
      }
    }

    return new Response("Accepted", { status: 200 });
  }
};
</code></pre>
<p>A queue consumer can then call the provider API, save the result and trigger email delivery.</p>
<h2>Use a state machine</h2>
<p>Each provisioning job should have an explicit status:</p>
<pre><code class="language-text">pending
provisioning
provisioned
email_pending
completed
failed
manual_review
</code></pre>
<p>Provisioning and email delivery should be treated as separate operations.</p>
<p>If the product has already been created but the email fails, the workflow should retry the email without calling the provider again.</p>
<h2>Handling an unknown provider result</h2>
<p>The hardest scenario occurs when the provider successfully creates the product but its response is lost.</p>
<p>The best solution is for the provider to support an idempotency key or searchable external reference.</p>
<p>Send the same provisioning key with every request:</p>
<pre><code class="language-javascript">body: JSON.stringify({
  externalReference: provisioningKey,
  productId,
  quantity: 1
})
</code></pre>
<p>Before retrying an uncertain request, query the provider using that external reference. If the product already exists, save the existing result instead of creating another one.</p>
<p>When the provider offers neither idempotency nor reference lookup, automatic retries can be dangerous. Jobs with uncertain outcomes should be moved to <code>manual_review</code>.</p>
<h2>Final architecture</h2>
<p>A robust digital fulfilment pipeline looks like this:</p>
<pre><code class="language-text">Shopify webhook
      ↓
Verify webhook
      ↓
Reserve unique provisioning job
      ↓
Queue the job
      ↓
Call provider API
      ↓
Store product details
      ↓
Send customer email
      ↓
Mark job as completed
</code></pre>
<p>This architecture is useful for any paid digital product where duplicate delivery creates a real cost.</p>
<p>At <a href="https://purasim.com">PuraSIM</a>, we are exploring this type of architecture for automating travel eSIM fulfilment while keeping provisioning and customer communication independently retryable.</p>
]]></content:encoded></item></channel></rss>