<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Alex Toska</title>
    <description>The latest articles on DEV Community by Alex Toska (@alex_toska_tsk).</description>
    <link>https://dev.to/alex_toska_tsk</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2003719%2F5d823b3a-243c-4c52-85ca-9618da96335d.png</url>
      <title>DEV Community: Alex Toska</title>
      <link>https://dev.to/alex_toska_tsk</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alex_toska_tsk"/>
    <language>en</language>
    <item>
      <title>Building a Real-Time Data Pipeline from Shopify to Meta's Marketing API</title>
      <dc:creator>Alex Toska</dc:creator>
      <pubDate>Fri, 19 Dec 2025 00:41:10 +0000</pubDate>
      <link>https://dev.to/alex_toska_tsk/building-a-real-time-data-pipeline-from-shopify-to-metas-marketing-api-236f</link>
      <guid>https://dev.to/alex_toska_tsk/building-a-real-time-data-pipeline-from-shopify-to-metas-marketing-api-236f</guid>
      <description>&lt;h2&gt;
  
  
  Building a Real-Time Data Pipeline from Shopify to Meta’s Marketing API
&lt;/h2&gt;

&lt;p&gt;I spent the last few months building &lt;strong&gt;&lt;a href="https://www.audience-plus.com" rel="noopener noreferrer"&gt;Audience+&lt;/a&gt;&lt;/strong&gt; — a tool that syncs Shopify customer data to Meta’s advertising platform in real time.&lt;/p&gt;

&lt;p&gt;Below is a clear, accessible technical breakdown of how it works, the challenges we solved, and concrete code patterns that may help if you’re building something similar.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem We’re Solving
&lt;/h2&gt;

&lt;p&gt;Meta’s browser-based tracking is fundamentally broken.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;iOS 14.5 App Tracking Transparency hides ~75% of iPhone users&lt;/li&gt;
&lt;li&gt;The Meta Pixel only retains data for &lt;strong&gt;180 days&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Meta optimizes on &lt;strong&gt;30–40% of real conversion data&lt;/strong&gt; for most stores&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The solution:&lt;/strong&gt; send &lt;strong&gt;first-party customer and purchase data&lt;/strong&gt; directly from Shopify to Meta using server-side APIs.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌───────────────┐   ┌───────────────┐   ┌───────────────┐
│ Shopify Store │──▶│  Audience+ API│──▶│   Meta API    │
│  (Webhooks)   │   │ (Processing)  │   │               │
└───────────────┘   └───────────────┘   └───────────────┘
                         │
                         ▼
                  ┌───────────────┐
                  │  PostgreSQL   │
                  │ (Customer DB) │
                  └───────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Tech Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Framework:&lt;/strong&gt; Next.js 15 (App Router)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Language:&lt;/strong&gt; TypeScript&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API Layer:&lt;/strong&gt; tRPC&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database:&lt;/strong&gt; PostgreSQL (Neon serverless)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ORM:&lt;/strong&gt; Prisma&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication:&lt;/strong&gt; Better-Auth + Shopify OAuth&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosting:&lt;/strong&gt; Vercel&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Shopify Webhook Integration
&lt;/h2&gt;

&lt;p&gt;Shopify sends webhooks for customer and order lifecycle events.&lt;br&gt;&lt;br&gt;
We verify each request using HMAC signatures before processing to ensure authenticity.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// app/api/webhooks/shopify/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';

export async function POST(req: NextRequest) {
  const body = await req.text();
  const hmac = req.headers.get('x-shopify-hmac-sha256');

  if (!verifyShopifyWebhook(body, hmac)) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
  }

  const topic = req.headers.get('x-shopify-topic');
  const payload = JSON.parse(body);

  switch (topic) {
    case 'orders/create':
      await handleNewOrder(payload);
      break;
    case 'customers/create':
      await handleNewCustomer(payload);
      break;
    case 'customers/update':
      await handleCustomerUpdate(payload);
      break;
  }

  return NextResponse.json({ received: true });
}

function verifyShopifyWebhook(body: string, hmac: string | null): boolean {
  if (!hmac) return false;

  const hash = crypto
    .createHmac('sha256', process.env.SHOPIFY_WEBHOOK_SECRET!)
    .update(body, 'utf8')
    .digest('base64');

  return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(hmac));
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Customer Data Normalization
&lt;/h2&gt;

&lt;p&gt;Customer data is normalized and hashed to meet Meta’s requirements&lt;br&gt;
(lowercase, trimmed, SHA-256).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// lib/customer-processor.ts
interface ShopifyCustomer {
  id: number;
  email: string;
  phone?: string;
  first_name?: string;
  last_name?: string;
  orders_count: number;
  total_spent: string;
  created_at: string;
}

interface MetaUserData {
  em?: string;
  ph?: string;
  fn?: string;
  ln?: string;
  external_id?: string;
}

function processCustomerForMeta(customer: ShopifyCustomer): MetaUserData {
  const userData: MetaUserData = {};

  if (customer.email) {
    userData.em = hashForMeta(customer.email.toLowerCase().trim());
  }

  if (customer.phone) {
    userData.ph = hashForMeta(normalizePhone(customer.phone));
  }

  if (customer.first_name) {
    userData.fn = hashForMeta(customer.first_name.toLowerCase().trim());
  }

  if (customer.last_name) {
    userData.ln = hashForMeta(customer.last_name.toLowerCase().trim());
  }

  userData.external_id = hashForMeta(customer.id.toString());

  return userData;
}

function hashForMeta(value: string): string {
  return crypto.createHash('sha256').update(value).digest('hex');
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Meta Marketing API Integration
&lt;/h2&gt;

&lt;p&gt;We integrate with two Meta APIs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Custom Audiences API&lt;/strong&gt; — for syncing customer lists&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conversions API&lt;/strong&gt; — for real-time server-side events&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Custom Audience Sync
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// lib/meta-audience-sync.ts
const META_API_VERSION = 'v18.0';

async function addUsersToAudience(
  audienceId: string,
  users: AudienceUser[],
  accessToken: string
): Promise&amp;lt;void&amp;gt; {
  const BATCH_SIZE = 10_000;
  const batches = chunk(users, BATCH_SIZE);

  for (const batch of batches) {
    const payload = {
      schema: ['EMAIL', 'PHONE', 'FN', 'LN'],
      data: batch.map(user =&amp;gt; [
        user.email ? hashForMeta(user.email.toLowerCase()) : '',
        user.phone ? hashForMeta(normalizePhone(user.phone)) : '',
        user.firstName ? hashForMeta(user.firstName.toLowerCase()) : '',
        user.lastName ? hashForMeta(user.lastName.toLowerCase()) : '',
      ]),
    };

    const response = await fetch(
      `https://graph.facebook.com/${META_API_VERSION}/${audienceId}/users`,
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ payload, access_token: accessToken }),
      }
    );

    if (!response.ok) {
      throw new Error(`Meta API error`);
    }

    await sleep(1000);
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Sending Purchase Events (Conversions API)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// lib/meta-conversions.ts
async function sendPurchaseEvent(
  pixelId: string,
  customer: ProcessedCustomer,
  order: ShopifyOrder,
  accessToken: string
) {
  const event = {
    event_name: 'Purchase',
    event_time: Math.floor(new Date(order.created_at).getTime() / 1000),
    event_id: `order_${order.id}`,
    action_source: 'website',
    user_data: {
      em: customer.hashedEmail,
      ph: customer.hashedPhone,
      client_ip_address: order.client_details?.browser_ip,
      client_user_agent: order.client_details?.user_agent,
    },
    custom_data: {
      value: parseFloat(order.total_price),
      currency: order.currency,
      content_ids: order.line_items.map(i =&amp;gt; i.product_id.toString()),
      content_type: 'product',
      num_items: order.line_items.reduce((s, i) =&amp;gt; s + i.quantity, 0),
    },
  };

  await fetch(
    `https://graph.facebook.com/${META_API_VERSION}/${pixelId}/events`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ data: [event], access_token: accessToken }),
    }
  );
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Audience Segmentation
&lt;/h2&gt;

&lt;p&gt;Customers are automatically classified into segments that stay in sync with Meta.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;enum CustomerSegment {
  NEW = 'new',
  ENGAGED = 'engaged',
  EXISTING = 'existing',
}

function classifyCustomer(customer: CustomerWithOrders): CustomerSegment {
  if (customer.orders_count === 0) return CustomerSegment.ENGAGED;
  if (customer.orders_count &amp;gt;= 1) return CustomerSegment.EXISTING;
  return CustomerSegment.NEW;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Key Challenges &amp;amp; Solutions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Rate Limiting
&lt;/h3&gt;

&lt;p&gt;Meta enforces strict limits. We use exponential backoff retries.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;async function withRetry&amp;lt;T&amp;gt;(
  fn: () =&amp;gt; Promise&amp;lt;T&amp;gt;,
  maxRetries = 3
): Promise&amp;lt;T&amp;gt; {
  let lastError: Error;

  for (let i = 0; i &amp;lt; maxRetries; i++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error as Error;
      await sleep(2 ** i * 1000);
    }
  }

  throw lastError!;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Webhook Idempotency
&lt;/h3&gt;

&lt;p&gt;Prevents duplicate processing from retries or out-of-order delivery.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;async function handleWebhookIdempotently(
  webhookId: string,
  handler: () =&amp;gt; Promise&amp;lt;void&amp;gt;
) {
  const existing = await prisma.processedWebhook.findUnique({
    where: { id: webhookId },
  });

  if (existing) return;

  await handler();

  await prisma.processedWebhook.create({
    data: { id: webhookId, processedAt: new Date() },
  });
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;Stores using Audience+ typically see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;50–100% more conversions&lt;/strong&gt; visible to Meta&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;10–20% ROAS improvement&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Correct exclusions and retargeting for the first time&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What I’d Do Differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Start with the &lt;strong&gt;Conversions API&lt;/strong&gt; first&lt;/li&gt;
&lt;li&gt;Add &lt;strong&gt;monitoring earlier&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Use &lt;strong&gt;queues from day one&lt;/strong&gt; instead of synchronous processing&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Try It Out
&lt;/h2&gt;

&lt;p&gt;If you want this without building it yourself, check out&lt;br&gt;
👉 &lt;strong&gt;&lt;a href="https://www.audience-plus.com" rel="noopener noreferrer"&gt;https://www.audience-plus.com&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;10-minute setup. Fully automated.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Have questions about the implementation? Drop them in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>saas</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
