Six steps, in order. Do them one at a time and do not skip ahead, because each step produces something the next step needs.
.myshopify.comhttps://www.yourstore.comAS8Vjc62tTLpDyVfkqQcxg.Shopify production purchases.openai-ads-shopify-capi. Click Deploy.https://openai-ads-shopify-capi.something.workers.dev. Copy it and save it. You need it in Step 3./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" }
});
}
/webhooks/orders-paid on the end, so it reads https://your-worker-address.workers.dev/webhooks/orders-paidopenai-ads-shopify-capi.| Name | Type | Value to enter |
|---|---|---|
OPENAI_ADS_CAPI_KEY | Secret | The key from Step 1 |
SHOPIFY_WEBHOOK_SECRET | Secret | The signing secret from Step 3 |
OPENAI_ADS_PIXEL_ID | Text | AS8Vjc62tTLpDyVfkqQcxg |
SHOPIFY_SHOP_DOMAIN | Text | Your store.myshopify.com address, with no https:// in front |
STORE_ORIGIN | Text | Your live store address, for example https://www.yourstore.com |
ALLOWED_SOURCE_NAME | Text | web |
VALIDATE_ONLY | Text | true |
ENABLE_HASHED_EMAIL | Text | false |
VALIDATE_ONLY is still true, so this test checks the plumbing without recording a real conversion.
.myshopify.com address in Step 4 is wrong. Check both and try again.VALIDATE_ONLY and click Edit.true to false.order_created event for that order. One, not two. Two would mean something is misconfigured, so tell us if you see that.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.