Hinweis: Diese Projektbeschreibung ist auf Englisch verfasst.

Case Study — Shopify → Pinterest

Checkout Signal

Ad blockers, Safari's ITP, and iOS quietly delete a chunk of every store's purchase signal before it reaches the ad platform. I built the server-side relay that reports it directly instead — hashed, deduplicated, and verified against a real order, not a demo.

7-day spike · verified end-to-end
27 / 27
Unit tests passing
1 / 1
Live order → events processed
order_id
Dedup key, cross-source
SHA-256
PII, hashed server-only

The problem

Most Shopify stores report a purchase to their ad platform with a single browser pixel firing after checkout. That pixel is exactly what ad blockers, tracking prevention, and iOS are built to stop — so the platform underreports conversions, optimizes against incomplete data, and the store never finds out.

A Conversions API (CAPI) fixes this by having the store's own server tell the ad platform "this person converted" — a channel no browser extension can touch. Meta, Pinterest, TikTok, and Snap all expose one, and they're structurally identical: a pixel and a server, a shared event id, hashed identity, a dedup window. I learned the mechanics on Meta's docs — the best-written of the four — and built the working pipe against Pinterest.

What I built

A Shopify app that relays checkout_completed to Pinterest, server-side

A web pixel extension runs inside Shopify's locked-down storefront sandbox, catches the checkout_completed event, and hands the raw fields to my app's server. The server — not the browser — stamps the shopper's real IP and user-agent, hashes any PII, and relays a Pinterest Conversions API event keyed to the order id.

How it works

1

Capture inside the sandbox

The web pixel subscribes to checkout_completed — the most reliable purchase signal Shopify exposes, since it only fires after the order is confirmed server-side — and pulls out just the fields the CAPI payload needs.

// extensions/capi-web-pixel/src/index.ts
analytics.subscribe("checkout_completed", (event) => {
  const extracted = {
    order_id: checkout.order?.id ?? checkout.token, // dedup key
    email: checkout.email,                          // hashed on the server, never here
    total: checkout.totalPrice?.amount,
    currency: checkout.currencyCode,
    lineItems: checkout.lineItems?.map(item => ({...})),
  };

  fetch(settings.collectEndpointUrl, {
    method: "POST",
    body: JSON.stringify(extracted),
  });
});
2

Stamp identity the sandbox can't see

Pinterest needs at least one identity signal per event — a hashed email, or the client IP and user-agent. The pixel sandbox has no access to the real client IP, so the server reads it off the request headers instead, checking the common proxy/CDN variants in priority order.

// app/routes/api.collect.tsx
const clientIpAddress =
  request.headers.get("cf-connecting-ip") ??
  request.headers.get("true-client-ip") ??
  request.headers.get("x-real-ip") ??
  request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
const clientUserAgent = request.headers.get("user-agent");
3

Hash before it ever leaves the server

Email and phone are normalized (trim, lowercase) and SHA-256 hashed in a platform-agnostic module — written once against Meta's spec, reused verbatim for Pinterest, since both require the same normalize-then-hash rule.

// app/lib/capi/hash.ts
export function normalizeAndHash(raw?: string | null): string | undefined {
  const normalized = raw?.trim().toLowerCase();
  return normalized ? sha256(normalized) : undefined;
}
4

Send once, retry on the failures worth retrying

A 4xx from Pinterest (bad payload, missing identity) won't succeed on a second try, so it returns immediately. A network error or 5xx gets exponential backoff. The function never throws — a failed forward gets logged, not crashed on.

// app/lib/capi/pinterestClient.ts
if (response.ok || response.status < 500) {
  return { ok: response.ok, status: response.status, result };
}
// 5xx and network errors: exponential backoff, then give up cleanly
await delay(retryDelayMs * 2 ** attempt);

Decisions I'd defend

Public route, not App Proxy
The pixel has no session and no admin auth context, so the collect endpoint is a plain public POST route rather than an authenticated App Proxy — CORS wide open by necessity, since the storefront origin isn't known in advance.
event_id = order_id
Simple, stable, and already the field both a client-side tag and this server relay would independently agree on — the whole point of a dedup key.
Consent gated at the source
The pixel checks Shopify's marketingAllowed signal and skips the forward entirely on an explicit opt-out, rather than collecting and filtering downstream.
PII redaction is the platform's default, not a bug
Since Dec 2025, Shopify nulls email/phone in pixel payloads unless the app is explicitly approved for protected customer data. Building against that meant designing the IP+UA fallback identity path from day one, not as a patch.

Honest scorecard