Easton Digital · Setup

OpenAI Ads purchase tracking
for Shopify

Six steps, in order. Do them one at a time and do not skip ahead, because each step produces something the next step needs.

Have these ready before you start
Two values you will collect along the way Step 1 gives you an OpenAI key. Step 3 gives you a Shopify signing secret. Keep both somewhere safe until Step 4, where you paste them in. Do not email them or put them in a chat message.
1

Get your OpenAI key

Where · OpenAI Ads Manager
  1. In the top menu, click Tools, then Conversions, then Data Source.
  2. Click the data source with the ID AS8Vjc62tTLpDyVfkqQcxg.
  3. Click Conversion keys. If you do not see that, click Conversions API.
  4. Click Create key and name it Shopify production purchases.
  5. Click Create. The key appears on screen.
  6. Copy the key now and paste it into a password manager or a note on your own computer. You will need it in Step 4.
You only see this key once If you close the window without copying it, go back and create a second key. Delete the first one.
2

Create the Cloudflare Worker

Where · dash.cloudflare.com
  1. Sign in to Cloudflare.
  2. In the left sidebar, click Workers & Pages.
  3. Click Create application, then Create Worker.
  4. In the name box, type openai-ads-shopify-capi. Click Deploy.
  5. Click Edit code.
  6. Click anywhere in the code panel, select all of the starter code and delete it, so the panel is empty.
  7. Copy the code block below with the Copy button and paste it into the empty panel.
  8. Click Deploy at the top right, then Save and deploy.
  9. At the top of the page, find your Worker address. It looks like https://openai-ads-shopify-capi.something.workers.dev. Copy it and save it. You need it in Step 3.
  10. Paste that address into a browser tab and add /health on the end. You should see {"ok":true,"service":"openai-ads-shopify-capi"}. If you see anything else, the code did not paste correctly. Redo steps 5 to 8.
const encoder = new TextEncoder();

export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    if (request.method === "GET" && url.pathname === "/health") {
      return json({ ok: true, service: "openai-ads-shopify-capi" });
    }

    if (request.method !== "POST" || url.pathname !== "/webhooks/orders-paid") {
      return new Response("Not found", { status: 404 });
    }

    const missing = requiredBindings(env);
    if (missing.length) {
      console.error("Missing required Worker bindings", missing.join(", "));
      return new Response("Server configuration error", { status: 500 });
    }

    const rawBody = await request.text();
    const shopifyHmac = request.headers.get("X-Shopify-Hmac-Sha256");
    const shopDomain = request.headers.get("X-Shopify-Shop-Domain");
    const topic = request.headers.get("X-Shopify-Topic");

    if (shopDomain !== env.SHOPIFY_SHOP_DOMAIN || topic !== "orders/paid") {
      return new Response("Unauthorized", { status: 401 });
    }

    const hmacValid = await verifyShopifyHmac(
      rawBody,
      shopifyHmac,
      env.SHOPIFY_WEBHOOK_SECRET
    );

    if (!hmacValid) {
      return new Response("Unauthorized", { status: 401 });
    }

    let order;
    try {
      order = JSON.parse(rawBody);
    } catch {
      return new Response("Invalid JSON", { status: 400 });
    }

    const validateOnly = env.VALIDATE_ONLY === "true";

    if (order.test === true && !validateOnly) {
      return json({ ok: true, skipped: "test_order" });
    }

    if (
      env.ALLOWED_SOURCE_NAME &&
      order.source_name &&
      order.source_name !== env.ALLOWED_SOURCE_NAME
    ) {
      return json({ ok: true, skipped: "non_storefront_order" });
    }

    const event = await buildOpenAIEvent(order, env);
    if (!event) {
      return new Response("Order payload is missing required fields", { status: 422 });
    }

    let capiResponse;
    try {
      capiResponse = await fetchWithTimeout(
        "https://bzr.openai.com/v1/events?pid=" +
          encodeURIComponent(env.OPENAI_ADS_PIXEL_ID),
        {
          method: "POST",
          headers: {
            Authorization: "Bearer " + env.OPENAI_ADS_CAPI_KEY,
            "Content-Type": "application/json"
          },
          body: JSON.stringify({
            validate_only: validateOnly,
            events: [event]
          })
        },
        3500
      );
    } catch {
      console.error("OpenAI CAPI request failed before receiving a response");
      return new Response("Upstream conversion service unavailable", { status: 502 });
    }

    if (!capiResponse.ok) {
      console.error("OpenAI CAPI returned status", capiResponse.status);
      return new Response("Upstream conversion service rejected the event", { status: 502 });
    }

    return json({ ok: true, validate_only: validateOnly });
  }
};

function requiredBindings(env) {
  return [
    "OPENAI_ADS_PIXEL_ID",
    "OPENAI_ADS_CAPI_KEY",
    "SHOPIFY_WEBHOOK_SECRET",
    "SHOPIFY_SHOP_DOMAIN",
    "STORE_ORIGIN"
  ].filter((name) => !env[name]);
}

async function verifyShopifyHmac(rawBody, base64Signature, secret) {
  if (!base64Signature || !secret) return false;
  let signature;
  try {
    signature = Uint8Array.from(atob(base64Signature), (character) =>
      character.charCodeAt(0)
    );
  } catch {
    return false;
  }

  const key = await crypto.subtle.importKey(
    "raw",
    encoder.encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["verify"]
  );

  return crypto.subtle.verify(
    "HMAC",
    key,
    signature,
    encoder.encode(rawBody)
  );
}

async function buildOpenAIEvent(order, env) {
  const orderId =
    order.admin_graphql_api_id ||
    (order.id ? "gid://shopify/Order/" + order.id : null);
  if (!orderId) return null;

  const currency = String(order.currency || "").toUpperCase();
  const total = toMinorUnits(
    order.current_total_price || order.total_price,
    currency
  );

  const data = {
    type: "contents",
    contents: (order.line_items || []).map((item) => ({
      id: String(item.sku || item.variant_id || item.product_id || item.id),
      name: String(item.name || item.title || "Product"),
      content_type: "product",
      quantity: Math.max(1, Math.trunc(Number(item.quantity) || 1))
    }))
  };

  if (total !== null && currency) {
    data.amount = total;
    data.currency = currency;
  }

  const event = {
    id: String(orderId),
    type: "order_created",
    timestamp_ms: Date.now(),
    source_url: sanitizedConfirmationUrl(env.STORE_ORIGIN),
    action_source: "web",
    data
  };

  if (env.ENABLE_HASHED_EMAIL === "true") {
    const email = order.email || order.contact_email;
    if (email) {
      event.user = {
        email_sha256: await sha256Hex(String(email).trim().toLowerCase())
      };
      const address = order.billing_address || order.shipping_address;
      if (address && address.country_code) {
        event.user.country = String(address.country_code).toUpperCase();
      }
      if (address && address.city) event.user.city = String(address.city);
      if (address && address.zip) event.user.zip_code = String(address.zip);
    }
  }

  return event;
}

function sanitizedConfirmationUrl(storeOrigin) {
  const parsed = new URL(storeOrigin);
  if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
    throw new Error("STORE_ORIGIN must use HTTP or HTTPS");
  }
  return parsed.origin + "/checkout/thank-you";
}

function toMinorUnits(value, currency) {
  if (value === null || value === undefined || value === "") return null;
  const number = Number(value);
  if (!Number.isFinite(number)) return null;

  const zeroDecimal = new Set([
    "BIF", "CLP", "DJF", "GNF", "JPY", "KMF", "KRW", "PYG",
    "RWF", "UGX", "VND", "VUV", "XAF", "XOF", "XPF"
  ]);
  const threeDecimal = new Set([
    "BHD", "IQD", "JOD", "KWD", "LYD", "OMR", "TND"
  ]);
  const decimals = zeroDecimal.has(currency) ? 0 : threeDecimal.has(currency) ? 3 : 2;
  return Math.round(number * 10 ** decimals);
}

async function sha256Hex(value) {
  const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value));
  return Array.from(new Uint8Array(digest), (byte) =>
    byte.toString(16).padStart(2, "0")
  ).join("");
}

async function fetchWithTimeout(url, options, timeoutMs) {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
  try {
    return await fetch(url, { ...options, signal: controller.signal });
  } finally {
    clearTimeout(timeout);
  }
}

function json(value, status = 200) {
  return new Response(JSON.stringify(value), {
    status,
    headers: { "Content-Type": "application/json; charset=utf-8" }
  });
}
3

Create the Shopify webhook

Where · Shopify Admin
  1. Click Settings at the bottom left, then Notifications.
  2. Scroll to Webhooks and click Create webhook.
  3. For Event, choose Order payment. Some accounts label this Order paid. Either is correct.
  4. For Format, choose JSON.
  5. For Webhook API version, leave the version Shopify selects for you.
  6. For URL, paste the Worker address you saved in Step 2 and add /webhooks/orders-paid on the end, so it reads https://your-worker-address.workers.dev/webhooks/orders-paid
  7. Click Save.
  8. Back on the Webhooks screen, find the line that says your webhooks are signed with a secret key. Copy that key and save it. You need it in Step 4.
If Shopify will not let you create a webhook here Some plans hide this screen. Stop and tell us. We will set it up a different way and hand you back the signing secret.
4

Enter the eight settings in Cloudflare

Where · Cloudflare, back in your Worker
  1. Go to Workers & Pages and click openai-ads-shopify-capi.
  2. Click Settings, then Variables and Secrets.
  3. Click Add. Enter the first row from the table below, matching the Type column exactly.
  4. Repeat until all eight rows are added.
  5. Click Deploy.
NameTypeValue to enter
OPENAI_ADS_CAPI_KEYSecretThe key from Step 1
SHOPIFY_WEBHOOK_SECRETSecretThe signing secret from Step 3
OPENAI_ADS_PIXEL_IDTextAS8Vjc62tTLpDyVfkqQcxg
SHOPIFY_SHOP_DOMAINTextYour store.myshopify.com address, with no https:// in front
STORE_ORIGINTextYour live store address, for example https://www.yourstore.com
ALLOWED_SOURCE_NAMETextweb
VALIDATE_ONLYTexttrue
ENABLE_HASHED_EMAILTextfalse
The first two must be set to Secret, not Text Cloudflare lets anyone with account access read a Text value. Choosing Secret hides them. The other six are fine as Text.
5

Run a test

Where · Shopify, then Cloudflare
Nothing counts yet VALIDATE_ONLY is still true, so this test checks the plumbing without recording a real conversion.
  1. In Shopify, go back to Settings, then Notifications, then Webhooks.
  2. Next to your webhook, click Send test notification. If you do not see that option, place a test order in your store and mark it paid.
  3. In Cloudflare, open your Worker and click Logs, then Begin log stream.
  4. Send the test notification again so it appears in the live stream.
  5. You are looking for a line showing status 200. That means it worked. Go to Step 6.
  6. If you see 401, the signing secret or the .myshopify.com address in Step 4 is wrong. Check both and try again.
  7. If you see 500, one of the eight settings is missing or misspelled. Check the table in Step 4 and try again.
  8. If you see 502, the OpenAI key is wrong. Redo Step 1 with a fresh key.
6

Turn it on

Where · Cloudflare, then OpenAI Ads Manager
  1. In Cloudflare, go to your Worker, then Settings, then Variables and Secrets.
  2. Find VALIDATE_ONLY and click Edit.
  3. Change the value from true to false.
  4. Click Deploy.
  5. Place one real order on your store for a low priced item, and pay for it normally. A Shopify test order will not work now.
  6. In OpenAI Ads Manager, go to Conversions, then Event Stream.
  7. Within a few minutes you should see one order_created event for that order. One, not two. Two would mean something is misconfigured, so tell us if you see that.
  8. Check that the amount and currency shown match what you actually paid.
  9. Refund your test order in Shopify as normal.

You are done

Purchases now reach OpenAI Ads from your server, not just from the browser, so they keep getting counted even when a shopper blocks tracking. Nothing about your checkout changed and customers see no difference.

If you got stuck, reply with the step number and what you saw on screen and we will take it from there.

Prepared by Easton Digital · August 2026