tva
← Insights

Reddit Pixel Tracking สำหรับ Shopify: การติดตั้งฝั่งเซิร์ฟเวอร์ด้วย Conversions API

การติดตามผลของ Reddit สำหรับอีคอมเมิร์ซเผชิญกับความท้าทายเดียวกันกับแพลตฟอร์มโฆษณาอื่นๆ: พิกเซลที่ทำงานบนเบราว์เซอร์ถูกบล็อกมากขึ้นเรื่อยๆ โดยตัวบล็อกโฆษณา การตั้งค่าความเป็นส่วนตัว และข้อจำกัดการติดตามของ iOS ตามเอกสารอย่างเป็นทางการของ Reddit Conversions API ให้การติดตามที่ “มีความทนทานมากกว่า” ซึ่ง “ได้รับผลกระทบจากตัวบล็อกโฆษณาและข้อจำกัดเบราว์เซอร์น้อยกว่า”

คู่มือนี้แสดงวิธีการติดตั้งระบบติดตาม Conversion ของ Reddit ฝั่งเซิร์ฟเวอร์สำหรับร้าน Shopify โดยใช้ Reddit Conversions API คุณจะได้โซลูชันที่พร้อมใช้งานจริงบน Docker ที่ส่งข้อมูล Conversion โดยตรงจากเซิร์ฟเวอร์ของคุณไปยัง Reddit โดยข้ามข้อจำกัดฝั่งไคลเอ็นต์ทั้งหมด

Reddit แนะนำอย่างเป็นทางการให้ใช้ทั้ง Pixel และ Conversions API ร่วมกัน จากเอกสารของพวกเขา: “เพื่อผลลัพธ์ที่ดีที่สุด เราแนะนำให้ผสานทั้ง Reddit Pixel และ Conversions API...CAPI ช่วยให้คุณได้ข้อมูลที่แม่นยำขึ้นและเพิ่มการครอบคลุม Conversion ของคุณ”


สารบัญ

  1. ทำไมการติดตามฝั่งเซิร์ฟเวอร์จึงจำเป็น
  2. ภาพรวมสถาปัตยกรรม
  3. ข้อกำหนดเบื้องต้น
  4. ขั้นตอนที่ 1: การตั้งค่า Reddit Ads Manager
  5. ขั้นตอนที่ 2: การสร้าง Conversion Tracker
  6. ขั้นตอนที่ 3: การตั้งค่า Docker
  7. ขั้นตอนที่ 4: Nginx Reverse Proxy
  8. ขั้นตอนที่ 5: ใบรับรอง SSL
  9. ขั้นตอนที่ 6: การตั้งค่า Shopify Webhook
  10. ขั้นตอนที่ 7: การทดสอบและการตรวจสอบ
  11. การแก้ปัญหาที่พบบ่อย
  12. การติดตามและการบำรุงรักษา

ทำไมการติดตามฝั่งเซิร์ฟเวอร์จึงจำเป็น

ข้อจำกัดของพิกเซลฝั่งไคลเอ็นต์

Reddit Pixel เป็นโค้ด JavaScript ที่ทำงานในเบราว์เซอร์ของผู้ใช้ แม้จะใช้งานได้สำหรับการติดตามพื้นฐาน แต่เผชิญกับข้อจำกัดที่สำคัญในปี 2025:

  • ตัวบล็อกโฆษณา: ส่วนขยายเบราว์เซอร์บล็อก request ของพิกเซลทั้งหมด
  • iOS App Tracking Transparency: ต้องได้รับความยินยอมจากผู้ใช้อย่างชัดแจ้ง
  • การบล็อกคุกกี้ของบุคคลที่สาม: Safari และ Firefox บล็อกโดยค่าเริ่มต้น
  • การตั้งค่าความเป็นส่วนตัว: เบราว์เซอร์สมัยใหม่จำกัดความสามารถในการติดตาม

ปัญหาเหล่านี้ไม่ใช่เรื่องทางทฤษฎี ตามข้อมูลในอุตสาหกรรม 30-50% ของเหตุการณ์ Conversion อาจสูญหายไปกับการติดตามฝั่งไคลเอ็นต์เพียงอย่างเดียว

คำแนะนำอย่างเป็นทางการของ Reddit

จากเอกสาร Conversions API ของ Reddit (แหล่งที่มา):

“CAPI มีความทนทานต่อการสูญเสียสัญญาณมากกว่าเพราะทำงานฝั่งเซิร์ฟเวอร์ ทำให้ได้รับผลกระทบจากตัวบล็อกโฆษณาและข้อจำกัดเบราว์เซอร์น้อยกว่า สิ่งนี้นำไปสู่การวัดผล การกำหนดเป้าหมาย และการปรับให้เหมาะสมที่ดีขึ้น”

Conversions API ส่งข้อมูลจากเซิร์ฟเวอร์สู่เซิร์ฟเวอร์ ไม่มีเบราว์เซอร์เกี่ยวข้องหมายความว่าไม่มีการบล็อก ไม่มีข้อความขอความยินยอม และมีความแม่นยำของข้อมูลครบถ้วน

ทำไมคุณต้องใช้ทั้งสองอย่าง

Reddit แนะนำให้ใช้ทั้ง Pixel และ CAPI ร่วมกัน:

  • Pixel: จับข้อมูลปฏิสัมพันธ์ฝั่งไคลเอ็นต์, click ID, บริบทเบราว์เซอร์
  • CAPI: มั่นใจว่าข้อมูล Conversion ถึง Reddit แม้เมื่อ Pixel ถูกบล็อก
  • การกรองข้อมูลซ้ำ: Reddit จัดการเหตุการณ์ซ้ำโดยอัตโนมัติเมื่อทั้งสองส่ง event_id เดียวกัน

คู่มือนี้ติดตั้งส่วน CAPI คุณสามารถเพิ่ม Pixel แยกต่างหากผ่าน customer events ของ Shopify


ภาพรวมสถาปัตยกรรม

วิธีการทำงาน

การติดตั้งนี้ใช้ Shopify webhook เพื่อจับเหตุการณ์คำสั่งซื้อฝั่งเซิร์ฟเวอร์ จากนั้นส่งต่อไปยัง Conversions API ของ Reddit:

Shopify Store (order created)
    |
    v
Shopify Webhook (HTTPS POST)
    |
    v
Nginx Reverse Proxy (SSL termination)
    |
    v
Node.js Application (Docker container)
    |
    v
1. HMAC Verification (security)
2. Event Transformation (Shopify -> Reddit format)
3. PII Hashing (SHA-256)
4. Deduplication Check (PostgreSQL)
5. Send to Reddit CAPI
6. Log Result
    |
    v
Reddit Ads Platform (conversion attributed)

ส่วนประกอบ

ส่วนประกอบวัตถุประสงค์เทคโนโลยี
Webhook Receiverรับเหตุการณ์คำสั่งซื้อจาก ShopifyNode.js/Express
HMAC Verificationตรวจสอบความถูกต้องของ webhookcrypto (SHA-256)
Event Transformerแปลงข้อมูล Shopify เป็นรูปแบบ RedditCustom service
Deduplicationป้องกันเหตุการณ์ซ้ำPostgreSQL
Reddit API Clientส่งเหตุการณ์ไปยัง RedditHTTP client พร้อมตรรกะ retry
Reverse ProxySSL termination, การจัดเส้นทางNginx

ข้อกำหนดเบื้องต้น

คุณต้องมี:

  • เซิร์ฟเวอร์: Linux VPS ที่ติดตั้ง Docker (RAM ขั้นต่ำ 2GB)
  • โดเมน: ซับโดเมนที่ชี้ไปยังเซิร์ฟเวอร์ของคุณ (เช่น shopify-events.yourdomain.com)
  • บัญชี Reddit Ads: บัญชีที่ใช้งานอยู่พร้อม Pixel ที่สร้างแล้ว
  • ร้าน Shopify: สิทธิ์ผู้ดูแลเพื่อตั้งค่า webhook
  • เครื่องมือ: การเข้าถึง SSH, ความรู้พื้นฐานเกี่ยวกับ command line

ขั้นตอนที่ 1: การตั้งค่า Reddit Ads Manager

สร้าง Pixel ของคุณ

  1. ไปที่ Reddit Ads Manager → Events Manager
  2. คลิก “Create New Pixel”
  3. ตั้งชื่อพิกเซลของคุณ (เช่น “Shopify Store Conversions”)
  4. คัดลอก Pixel ID (รูปแบบ: t2_abc123)

สร้าง Access Token สำหรับ Conversions API

  1. ใน Events Manager คลิกที่พิกเซลของคุณ
  2. ไปที่ “Settings” → “Conversions API”
  3. คลิก “Generate Access Token”
  4. คัดลอกโทเค็น (เริ่มต้นด้วย Bearer ey...)
  5. สำคัญ: เก็บโทเค็นนี้อย่างปลอดภัย คุณจะไม่เห็นมันอีกครั้ง

ตอนนี้คุณมี:

  • REDDIT_PIXEL_ID (เช่น t2_abc123)
  • REDDIT_ACCESS_TOKEN (เช่น eyJhbGciOiJSUzI1NiIsImtpZCI...)

ขั้นตอนที่ 2: การสร้าง Conversion Tracker

สร้างโครงสร้างไดเรกทอรีโปรเจกต์:

mkdir -p /opt/reddit-capi/src/{routes,middleware,services,utils}
cd /opt/reddit-capi

package.json

{
  "name": "reddit-capi",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node src/index.js"
  },
  "dependencies": {
    "express": "^4.18.2",
    "pg": "^8.11.3",
    "winston": "^3.11.0",
    "uuid": "^9.0.1"
  }
}

src/config.js

export const config = {
  port: process.env.PORT || 3000,

  shopify: {
    webhookSecret: process.env.SHOPIFY_WEBHOOK_SECRET,
  },

  reddit: {
    pixelId: process.env.REDDIT_PIXEL_ID,
    accessToken: process.env.REDDIT_ACCESS_TOKEN,
    apiUrl: 'https://ads-api.reddit.com/api/v2.0/conversions/events',
  },

  database: {
    host: process.env.DB_HOST || 'postgres',
    port: process.env.DB_PORT || 5432,
    database: process.env.DB_NAME || 'reddit_capi',
    user: process.env.DB_USER || 'reddit_capi',
    password: process.env.DB_PASSWORD,
  },
};

src/utils/logger.js

import winston from 'winston';

export const logger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [
    new winston.transports.Console({
      format: winston.format.combine(
        winston.format.colorize(),
        winston.format.simple()
      ),
    }),
  ],
});

src/middleware/shopifyVerify.js

middleware นี้จัดการการตรวจสอบ HMAC และการแยกวิเคราะห์ request body:

import crypto from 'crypto';
import { config } from '../config.js';
import { logger } from '../utils/logger.js';

// Read raw body for HMAC verification and parse JSON
export function rawBodyCapture(req, res, next) {
  let data = '';

  req.on('data', (chunk) => {
    data += chunk;
  });

  req.on('end', () => {
    // Store raw body for HMAC verification
    req.rawBody = data;

    // Parse JSON and set as req.body
    // Important: Don't use express.json() middleware - it consumes the stream
    try {
      req.body = JSON.parse(data);
    } catch (error) {
      logger.error('Failed to parse JSON body:', error);
      return res.status(400).json({ error: 'Invalid JSON' });
    }

    next();
  });
}

export function verifyShopifyWebhook(req, res, next) {
  const hmacHeader = req.get('X-Shopify-Hmac-SHA256');

  if (!hmacHeader) {
    logger.warn('Missing HMAC header');
    return res.status(401).json({ error: 'Unauthorized - Missing HMAC' });
  }

  // Calculate HMAC using raw body
  const hash = crypto
    .createHmac('sha256', config.shopify.webhookSecret)
    .update(req.rawBody)
    .digest('base64');

  if (hash !== hmacHeader) {
    logger.warn('HMAC verification failed');
    return res.status(401).json({ error: 'Unauthorized - Invalid HMAC' });
  }

  logger.info('Webhook HMAC verified', {
    topic: req.get('X-Shopify-Topic'),
  });

  next();
}

src/services/crypto.js

import crypto from 'crypto';

export function hashEmail(email) {
  if (!email) return null;

  // Normalize: lowercase and trim
  const normalized = email.toLowerCase().trim();

  // SHA-256 hash
  return crypto.createHash('sha256').update(normalized).digest('hex');
}

export function hashPhone(phone) {
  if (!phone) return null;

  // Remove all non-digits
  const normalized = phone.replace(/\D/g, '');

  return crypto.createHash('sha256').update(normalized).digest('hex');
}

src/services/shopifyTransformer.js

แปลงข้อมูลคำสั่งซื้อจาก Shopify เป็นรูปแบบ Reddit CAPI:

import { v4 as uuidv4 } from 'uuid';
import { hashEmail, hashPhone } from './crypto.js';

export function transformOrderToPurchase(order) {
  const eventId = `shopify_order_${order.id}_${Date.now()}`;

  const redditEvent = {
    event_at: order.created_at,
    event_type: {
      tracking_type: 'Purchase',
    },
    user: {
      // Hash PII for privacy
      email: hashEmail(order.email),
      ...(order.phone && { phone_number: hashPhone(order.phone) }),

      // IP and User Agent if available
      ...(order.browser_ip && { ip_address: order.browser_ip }),
      ...(order.client_details?.user_agent && {
        user_agent: order.client_details.user_agent
      }),
    },
    event_metadata: {
      // Reddit requires value as integer in minor currency units (cents)
      value: Math.round((parseFloat(order.total_price) || 0) * 100),

      // Unique ID for deduplication
      conversion_id: String(order.id),

      currency: order.currency,

      // Optional: Product details
      item_count: order.line_items?.length || 0,

      // Custom data for tracking
      order_number: order.order_number,
    },
  };

  return {
    event_id: eventId,
    event_type: 'Purchase',
    shopify_id: String(order.id),
    reddit_payload: redditEvent,
  };
}

export function transformCheckoutToLead(checkout) {
  const eventId = `shopify_checkout_${checkout.id}_${Date.now()}`;

  const redditEvent = {
    event_at: checkout.created_at,
    event_type: {
      tracking_type: 'Lead',
    },
    user: {
      email: hashEmail(checkout.email),
      ...(checkout.phone && { phone_number: hashPhone(checkout.phone) }),
    },
    event_metadata: {
      value: Math.round((parseFloat(checkout.total_price) || 0) * 100),
      conversion_id: String(checkout.id),
      currency: checkout.currency,
    },
  };

  return {
    event_id: eventId,
    event_type: 'Lead',
    shopify_id: String(checkout.id),
    reddit_payload: redditEvent,
  };
}

รายละเอียดสำคัญ: API ของ Reddit ต้องการ value เป็นจำนวนเต็มที่แสดงจำนวนเงินในหน่วยสกุลเงินย่อย (เซนต์สำหรับ USD, เพนนีสำหรับ GBP เป็นต้น) นี่คือเหตุผลที่เราคูณด้วย 100 และปัดเศษ

src/services/deduplication.js

import pg from 'pg';
import { config } from '../config.js';
import { logger } from '../utils/logger.js';

const { Pool } = pg;

const pool = new Pool(config.database);

export async function isDuplicate(eventId) {
  const result = await pool.query(
    'SELECT event_id FROM events WHERE event_id = $1',
    [eventId]
  );

  return result.rows.length > 0;
}

export async function saveEvent(eventData) {
  const {
    event_id,
    event_type,
    shopify_id,
    shopify_payload,
    reddit_payload,
  } = eventData;

  await pool.query(
    `INSERT INTO events (event_id, event_type, shopify_id, shopify_payload, reddit_payload, status)
     VALUES ($1, $2, $3, $4, $5, 'pending')`,
    [
      event_id,
      event_type,
      shopify_id,
      JSON.stringify(shopify_payload),
      JSON.stringify(reddit_payload),
    ]
  );

  logger.info('Event saved to database', { event_id, event_type });
}

export async function markEventSent(eventId, response) {
  await pool.query(
    `UPDATE events
     SET status = 'sent', sent_at = NOW(), reddit_response = $2
     WHERE event_id = $1`,
    [eventId, JSON.stringify(response)]
  );
}

export async function markEventFailed(eventId, error) {
  await pool.query(
    `UPDATE events
     SET status = 'failed', error_message = $2
     WHERE event_id = $1`,
    [eventId, error.message]
  );
}

src/services/redditCapi.js

import { config } from '../config.js';
import { logger } from '../utils/logger.js';

export async function sendToReddit(eventData) {
  const payload = {
    events: [eventData],
  };

  logger.info('Sending event to Reddit CAPI', {
    tracking_type: eventData.event_type.tracking_type,
    conversion_id: eventData.event_metadata.conversion_id,
  });

  // Retry logic: 3 attempts with exponential backoff
  let lastError;

  for (let attempt = 1; attempt <= 3; attempt++) {
    try {
      const response = await fetch(config.reddit.apiUrl, {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${config.reddit.accessToken}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(payload),
      });

      const responseText = await response.text();

      if (response.ok) {
        logger.info('Reddit CAPI success', {
          status: response.status,
          attempt,
        });

        return {
          success: true,
          status: response.status,
          response: responseText,
        };
      }

      // Handle rate limiting
      if (response.status === 429) {
        const retryAfter = parseInt(response.headers.get('Retry-After') || '60');
        logger.warn(`Rate limited by Reddit, retry after ${retryAfter}s`);

        await sleep(retryAfter * 1000);
        continue;
      }

      // Log error but retry
      logger.error('Reddit API error', {
        status: response.status,
        response: responseText,
        attempt,
      });

      lastError = new Error(`Reddit API error: ${response.status} - ${responseText}`);

      // Exponential backoff: 1s, 2s, 4s
      if (attempt < 3) {
        await sleep(Math.pow(2, attempt - 1) * 1000);
      }

    } catch (error) {
      logger.error('Network error sending to Reddit', {
        error: error.message,
        attempt,
      });

      lastError = error;

      if (attempt < 3) {
        await sleep(Math.pow(2, attempt - 1) * 1000);
      }
    }
  }

  throw lastError;
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

src/routes/webhooks.js

import express from 'express';
import { logger } from '../utils/logger.js';
import * as shopifyTransformer from '../services/shopifyTransformer.js';
import * as deduplication from '../services/deduplication.js';
import * as redditCapi from '../services/redditCapi.js';

const router = express.Router();

router.post('/webhooks/shopify', async (req, res) => {
  const topic = req.get('X-Shopify-Topic');
  const shopDomain = req.get('X-Shopify-Shop-Domain');

  logger.info('Received Shopify webhook', { topic, shopDomain });

  try {
    let eventData;

    // Route based on webhook topic
    switch (topic) {
      case 'orders/create':
        eventData = shopifyTransformer.transformOrderToPurchase(req.body);
        break;

      case 'checkouts/create':
        eventData = shopifyTransformer.transformCheckoutToLead(req.body);
        break;

      default:
        logger.info('Unsupported webhook topic', { topic });
        return res.status(200).json({
          status: 'ignored',
          message: `Topic ${topic} not supported`,
        });
    }

    // Check for duplicates
    if (await deduplication.isDuplicate(eventData.event_id)) {
      logger.info('Duplicate event detected', { event_id: eventData.event_id });
      return res.status(200).json({ status: 'duplicate' });
    }

    // Save to database
    await deduplication.saveEvent({
      ...eventData,
      shopify_payload: req.body,
    });

    // Send to Reddit CAPI
    try {
      const result = await redditCapi.sendToReddit(eventData.reddit_payload);

      await deduplication.markEventSent(eventData.event_id, result);

      return res.status(200).json({
        status: 'success',
        event_id: eventData.event_id,
      });

    } catch (error) {
      await deduplication.markEventFailed(eventData.event_id, error);

      // Still return 200 to Shopify so it doesn't retry
      return res.status(200).json({
        status: 'failed',
        event_id: eventData.event_id,
        error: error.message,
      });
    }

  } catch (error) {
    logger.error('Error processing webhook', { error: error.message });

    return res.status(500).json({
      error: 'Internal server error',
      message: error.message,
    });
  }
});

export default router;

src/routes/health.js

import express from 'express';
import pg from 'pg';
import { config } from '../config.js';

const router = express.Router();
const { Pool } = pg;
const pool = new Pool(config.database);

router.get('/health', async (req, res) => {
  try {
    // Check database connection
    await pool.query('SELECT 1');

    res.json({
      status: 'healthy',
      timestamp: new Date().toISOString(),
      uptime: process.uptime(),
      database: 'connected',
    });
  } catch (error) {
    res.status(503).json({
      status: 'unhealthy',
      error: error.message,
    });
  }
});

export default router;

src/index.js

import express from 'express';
import { config } from './config.js';
import { logger } from './utils/logger.js';
import { rawBodyCapture, verifyShopifyWebhook } from './middleware/shopifyVerify.js';
import healthRouter from './routes/health.js';
import webhooksRouter from './routes/webhooks.js';

const app = express();

// Health check (no authentication)
app.use('/', healthRouter);

// Shopify webhooks (with HMAC verification)
// Important: rawBodyCapture must come before verifyShopifyWebhook
app.use('/webhooks/shopify', rawBodyCapture);
app.use('/webhooks/shopify', verifyShopifyWebhook);
app.use('/', webhooksRouter);

const server = app.listen(config.port, () => {
  logger.info(`Server running on port ${config.port}`);
});

// Graceful shutdown
process.on('SIGTERM', () => {
  logger.info('SIGTERM received, closing server');
  server.close(() => {
    logger.info('Server closed');
    process.exit(0);
  });
});

ขั้นตอนที่ 3: การตั้งค่า Docker

Dockerfile

FROM node:18-alpine

WORKDIR /app

# Install dependencies
COPY package*.json ./
RUN npm ci --only=production

# Copy application code
COPY src ./src

# Run as non-root user
USER node

EXPOSE 3000

CMD ["node", "src/index.js"]

docker-compose.yml

version: '3.8'

services:
  reddit-capi:
    build: .
    ports:
      # Bind only to localhost - not publicly accessible
      - "127.0.0.1:3000:3000"
    environment:
      - PORT=3000
      - SHOPIFY_WEBHOOK_SECRET=${SHOPIFY_WEBHOOK_SECRET}
      - REDDIT_PIXEL_ID=${REDDIT_PIXEL_ID}
      - REDDIT_ACCESS_TOKEN=${REDDIT_ACCESS_TOKEN}
      - DB_HOST=postgres
      - DB_PORT=5432
      - DB_NAME=reddit_capi
      - DB_USER=reddit_capi
      - DB_PASSWORD=${DB_PASSWORD}
    depends_on:
      - postgres
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3

  postgres:
    image: postgres:16-alpine
    environment:
      - POSTGRES_DB=reddit_capi
      - POSTGRES_USER=reddit_capi
      - POSTGRES_PASSWORD=${DB_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    restart: unless-stopped

volumes:
  pgdata:

init.sql

CREATE TABLE IF NOT EXISTS events (
  event_id VARCHAR(255) PRIMARY KEY,
  event_type VARCHAR(50) NOT NULL,
  shopify_id VARCHAR(255) NOT NULL,
  shopify_payload JSONB,
  reddit_payload JSONB,
  reddit_response JSONB,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  sent_at TIMESTAMP,
  status VARCHAR(20) DEFAULT 'pending',
  error_message TEXT
);

CREATE INDEX idx_events_status ON events(status);
CREATE INDEX idx_events_created_at ON events(created_at);
CREATE INDEX idx_events_shopify_id ON events(shopify_id);

.env

# Shopify Configuration
SHOPIFY_WEBHOOK_SECRET=your_shopify_webhook_secret_here

# Reddit Configuration
REDDIT_PIXEL_ID=t2_your_pixel_id
REDDIT_ACCESS_TOKEN=your_reddit_access_token_here

# Database Configuration
DB_PASSWORD=generate_strong_password_here

ความปลอดภัย: ตั้งสิทธิ์จำกัดบนไฟล์ .env:

chmod 600 .env

ขั้นตอนที่ 4: Nginx Reverse Proxy

สร้างไฟล์การตั้งค่า Nginx:

# /etc/nginx/sites-available/shopify-events.yourdomain.com.conf

server {
    listen 80;
    server_name shopify-events.yourdomain.com;

    # Let's Encrypt ACME challenge
    location /.well-known/acme-challenge/ {
        root /var/www/html;
    }

    # Redirect all other HTTP traffic to HTTPS
    location / {
        return 301 https://$host$request_uri;
    }
}

เปิดใช้งานไซต์:

ln -s /etc/nginx/sites-available/shopify-events.yourdomain.com.conf /etc/nginx/sites-enabled/
nginx -t
systemctl reload nginx

ขั้นตอนที่ 5: ใบรับรอง SSL

ติดตั้ง Certbot หากยังไม่ได้ติดตั้ง:

apt update
apt install certbot

สร้างไดเรกทอรี webroot:

mkdir -p /var/www/html/.well-known/acme-challenge

สร้างใบรับรอง:

certbot certonly --webroot \
  -w /var/www/html \
  -d shopify-events.yourdomain.com

อัปเดตการตั้งค่า Nginx เพื่อใช้ SSL:

# Add this server block to /etc/nginx/sites-available/shopify-events.yourdomain.com.conf

server {
    listen 443 ssl http2;
    server_name shopify-events.yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/shopify-events.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/shopify-events.yourdomain.com/privkey.pem;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers on;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;

        proxy_http_version 1.1;
        proxy_set_header Connection "";

        // Timeouts for webhook processing
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
    }
}

ทดสอบและโหลดใหม่:

nginx -t
systemctl reload nginx

ตรวจสอบว่า HTTPS ทำงาน:

curl -vI https://shopify-events.yourdomain.com/health

คุณควรเห็น:

  • HTTP/2 200
  • ใบรับรอง SSL ที่ถูกต้อง
  • การตอบสนอง JSON ที่มี {"status":"healthy"}

ขั้นตอนที่ 6: การตั้งค่า Shopify Webhook

เริ่มแอปพลิเคชัน

cd /opt/reddit-capi
docker compose up -d

ตรวจสอบ log:

docker compose logs -f reddit-capi

คุณควรเห็น: Server running on port 3000

ตั้งค่า Webhook ใน Shopify

  1. ไปที่ Shopify Admin → Settings → Notifications
  2. เลื่อนลงไปที่ “Webhooks”
  3. คลิก “Create webhook”

Webhook สำหรับการสร้างคำสั่งซื้อ:

  • เหตุการณ์: Order creation
  • รูปแบบ: JSON
  • URL: https://shopify-events.yourdomain.com/webhooks/shopify
  • เวอร์ชัน API: ล่าสุด (2025-01 หรือใหม่กว่า)

คลิก “Save webhook”

คัดลอก Webhook Signing Secret:

หลังจากสร้าง webhook แล้ว Shopify จะแสดง signing secret คัดลอกมันและเพิ่มลงในไฟล์ .env ของคุณ:

echo 'SHOPIFY_WEBHOOK_SECRET=your_actual_secret_here' >> /opt/reddit-capi/.env

รีสตาร์ท container เพื่อใช้ secret ใหม่:

docker compose restart reddit-capi

ทางเลือก: Webhook สำหรับการสร้าง Checkout

ทำซ้ำกระบวนการเดียวกันสำหรับเหตุการณ์ “Checkout creation” หากคุณต้องการติดตามตะกร้าสินค้าที่ถูกทิ้งเป็นเหตุการณ์ “Lead” ใน Reddit


ขั้นตอนที่ 7: การทดสอบและการตรวจสอบ

ทดสอบ HMAC โดยไม่มีลายเซ็นที่ถูกต้อง

curl https://shopify-events.yourdomain.com/webhooks/shopify

การตอบสนองที่คาดหวัง:

{"error":"Unauthorized - Missing HMAC"}

นี่ยืนยันว่าความปลอดภัยทำงานอยู่

ส่ง Webhook ทดสอบจาก Shopify

  1. ใน Shopify Admin ไปที่ webhook ที่คุณสร้าง
  2. คลิก “Send test notification”
  3. ดู log:
docker compose logs -f reddit-capi

คุณควรเห็น:

INFO: Webhook HMAC verified {"topic":"orders/create"}
INFO: Received Shopify webhook {"topic":"orders/create","shopDomain":"your-store.myshopify.com"}
INFO: Event saved to database {"event_id":"shopify_order_...","event_type":"Purchase"}
INFO: Sending event to Reddit CAPI {"tracking_type":"Purchase","conversion_id":"..."}
INFO: Reddit CAPI success {"status":200,"attempt":1}

ตรวจสอบใน Reddit Events Manager

  1. ไปที่ Reddit Ads Manager → Events Manager
  2. คลิกที่พิกเซลของคุณ
  3. ไปที่แท็บ “Test Events”
  4. คุณควรเห็นเหตุการณ์ทดสอบปรากฏขึ้นภายในไม่กี่นาที

ตรวจสอบฐานข้อมูล

docker compose exec postgres psql -U reddit_capi -d reddit_capi -c \
  "SELECT event_id, event_type, status, sent_at FROM events ORDER BY created_at DESC LIMIT 5;"

คุณควรเห็นเหตุการณ์ทดสอบที่มี status = 'sent'

สร้างคำสั่งซื้อทดสอบจริง

ใช้ฟีเจอร์ “Test order” ของ Shopify หรือสร้างคำสั่งซื้อจริงในร้านพัฒนาของคุณ Webhook จะทำงานโดยอัตโนมัติ และคุณจะเห็น Conversion ใน Reddit ภายใน 15-30 นาที


การแก้ปัญหาที่พบบ่อย

ปัญหา: เหตุการณ์ไม่ปรากฏใน Reddit

ตรวจสอบ log สำหรับข้อผิดพลาด Reddit API:

docker compose logs reddit-capi | grep -i "reddit api error"

ข้อผิดพลาดที่พบบ่อย:

ข้อผิดพลาดสาเหตุวิธีแก้ไข
“unexpected type number”value ส่งเป็นทศนิยมแทนที่จะเป็นจำนวนเต็มตรวจสอบว่าคุณคูณด้วย 100 และใช้ Math.round()
“unknown field event_id”ส่ง event_id ใน Reddit payloadลบ event_id ออกจาก redditEvent ใช้ conversion_id แทน
“missing required field”ขาด event_at, event_type หรือข้อมูลผู้ใช้ตรวจสอบว่า transformer ตั้งค่าฟีลด์ที่จำเป็นทั้งหมด
401 UnauthorizedAccess token ไม่ถูกต้องสร้างโทเค็นใหม่ใน Reddit Events Manager
429 Too Many Requestsถูกจำกัดอัตรารอช่วงเวลา retry-after (จัดการอัตโนมัติ)

ปัญหา: Shopify Webhook ล้มเหลว

ตรวจสอบสถานะ Shopify webhook:

ใน Shopify Admin → Settings → Notifications → Webhooks ตรวจสอบว่า webhook แสดงข้อผิดพลาดหรือไม่

ปัญหาที่พบบ่อย:

  • การตรวจสอบ HMAC ล้มเหลว: Secret ไม่ตรงกัน คัดลอก secret ที่ถูกต้องจาก Shopify และอัปเดตไฟล์ .env
  • หมดเวลา: เซิร์ฟเวอร์ไม่ตอบสนองภายใน 5 วินาที ตรวจสอบว่า container ทำงานอยู่และมีสถานะปกติ
  • ข้อผิดพลาด SSL: ใบรับรองไม่ถูกต้อง ตรวจสอบด้วย curl -vI https://your-domain.com/health

ปัญหา: “stream is not readable”

ข้อผิดพลาดนี้เกิดขึ้นเมื่อทั้ง rawBodyCapture และ express.json() พยายามอ่าน request body

วิธีแก้ไข: ลบ middleware express.json() ออกจากเส้นทาง webhook ฟังก์ชัน rawBodyCapture จัดการทั้งการจับ raw body และการแยกวิเคราะห์ JSON

ไม่ถูกต้อง:

app.use('/webhooks/shopify', express.json());  // ❌ Don't use this
app.use('/webhooks/shopify', rawBodyCapture);

ถูกต้อง:

app.use('/webhooks/shopify', rawBodyCapture);  // ✅ Handles both

ปัญหา: Conversion ซ้ำ

หากคุณใช้ทั้ง Reddit Pixel และ CAPI ให้แน่ใจว่าคุณใช้ conversion_id เดียวกันในทั้งสอง

การติดตั้ง Pixel (ในหน้าขอบคุณของ Shopify):

rdt('track', 'Purchase', {
  transactionId: '{{ order.id }}',  // Must match conversion_id in CAPI
  value: {{ order.total_price }},
  currency: '{{ order.currency }}'
});

Reddit จะกรองเหตุการณ์ซ้ำที่มี conversion_id เดียวกันโดยอัตโนมัติ

ปัญหา: การเชื่อมต่อฐานข้อมูลล้มเหลว

# Check if PostgreSQL is running
docker compose ps postgres

# Test connection
docker compose exec postgres pg_isready -U reddit_capi

# Check logs
docker compose logs postgres

การติดตามและการบำรุงรักษา

การติดตาม Log

Log แบบเรียลไทม์:

docker compose logs -f reddit-capi

ค้นหาข้อผิดพลาด:

docker compose logs reddit-capi | grep -i error

กรองตามประเภทเหตุการณ์:

docker compose logs reddit-capi | grep "Purchase"

คำสั่งฐานข้อมูล

เหตุการณ์ล่าสุด:

docker compose exec postgres psql -U reddit_capi -d reddit_capi -c \
  "SELECT event_id, event_type, status, created_at, sent_at
   FROM events
   ORDER BY created_at DESC
   LIMIT 20;"

เหตุการณ์ที่ล้มเหลว:

docker compose exec postgres psql -U reddit_capi -d reddit_capi -c \
  "SELECT event_id, event_type, error_message, created_at
   FROM events
   WHERE status = 'failed'
   ORDER BY created_at DESC;"

สถิติเหตุการณ์:

docker compose exec postgres psql -U reddit_capi -d reddit_capi -c \
  "SELECT event_type, status, COUNT(*) as count
   FROM events
   GROUP BY event_type, status
   ORDER BY event_type, status;"

การตรวจสอบสถานะ

ตั้งค่า cron job เพื่อติดตาม health endpoint:

*/5 * * * * curl -sf https://shopify-events.yourdomain.com/health > /dev/null || echo "Reddit CAPI health check failed" | mail -s "Alert" your@email.com

การต่ออายุใบรับรอง SSL

ใบรับรอง Let’s Encrypt มีอายุ 90 วัน Certbot ควรต่ออายุโดยอัตโนมัติ

ทดสอบการต่ออายุ:

certbot renew --dry-run

ตรวจสอบวันหมดอายุใบรับรอง:

certbot certificates

การอัปเดต Container

อัปเดตหลังแก้ไขโค้ด:

docker compose down
docker compose up -d --build

ดูสถานะ container:

docker compose ps

รีสตาร์ทโดยไม่ต้อง build ใหม่:

docker compose restart reddit-capi

การตั้งค่าขั้นสูง

ประเภทเหตุการณ์ที่กำหนดเอง

Reddit รองรับเหตุการณ์มาตรฐานเพิ่มเติม คุณสามารถขยาย transformer เพื่อจัดการ Shopify webhook เพิ่มเติม:

View Content (การดูสินค้า):

// Add to shopifyTransformer.js
export function transformProductView(product) {
  return {
    event_at: new Date().toISOString(),
    event_type: {
      tracking_type: 'ViewContent',
    },
    user: { /* user data */ },
    event_metadata: {
      conversion_id: `product_${product.id}_${Date.now()}`,
      item_id: String(product.id),
      value: Math.round(parseFloat(product.price) * 100),
      currency: 'USD',
    },
  };
}

เพิ่มลงตะกร้า:

case 'carts/update':
  eventData = transformCartUpdate(req.body);
  break;

การติดตามระดับสินค้า

รวมรายละเอียดสินค้าใน event metadata:

event_metadata: {
  value: Math.round((parseFloat(order.total_price) || 0) * 100),
  conversion_id: String(order.id),
  currency: order.currency,

  // Product details
  products: order.line_items.map(item => ({
    id: String(item.product_id),
    name: item.title,
    quantity: item.quantity,
    price: Math.round(parseFloat(item.price) * 100),
  })),
},

การรองรับหลายสกุลเงิน

การติดตั้งนี้จัดการหลายสกุลเงินอยู่แล้วผ่านฟีลด์ order.currency ของ Shopify ให้แน่ใจว่าคุณแปลงค่าเป็นหน่วยสกุลเงินย่อยอย่างถูกต้องเสมอ:

  • USD, EUR, CAD: คูณด้วย 100 (เซนต์)
  • JPY, KRW: ไม่ต้องคูณ (เป็นหน่วยย่อยอยู่แล้ว)
  • BHD, KWD: คูณด้วย 1000 (3 ตำแหน่งทศนิยม)

สำหรับสกุลเงินที่มีตำแหน่งทศนิยมต่างกัน อัปเดต transformer:

function getMinorUnitMultiplier(currency) {
  const zeroDecimalCurrencies = ['JPY', 'KRW', 'CLP'];
  const threeDecimalCurrencies = ['BHD', 'KWD', 'OMR'];

  if (zeroDecimalCurrencies.includes(currency)) return 1;
  if (threeDecimalCurrencies.includes(currency)) return 1000;
  return 100;
}

// In transformer:
value: Math.round(parseFloat(order.total_price) * getMinorUnitMultiplier(order.currency))

ขั้นตอนต่อไป

การติดตั้งนี้ให้ระบบติดตาม Conversion ฝั่งเซิร์ฟเวอร์สำหรับ Reddit Ads ที่พร้อมใช้งานจริง สถาปัตยกรรมถูกออกแบบเพื่อความน่าเชื่อถือ พร้อมการตรวจสอบ HMAC การกรองข้อมูลซ้ำ ตรรกะ retry และการบันทึก log อย่างครอบคลุม

คุณสามารถขยายพื้นฐานนี้ด้วย:

  • ประเภทเหตุการณ์เพิ่มเติม: การดูสินค้า การทิ้งตะกร้า การค้นหา
  • การจับคู่ผู้ใช้ที่ดีขึ้น: รวม Reddit click ID จากพารามิเตอร์ URL
  • การแจ้งเตือน: ผสานกับเครื่องมือติดตามสำหรับเหตุการณ์ที่ล้มเหลว
  • แดชบอร์ดวิเคราะห์: แสดงข้อมูล Conversion จาก PostgreSQL

สำหรับการอ้างอิง ซอร์สโค้ดทั้งหมดและการตั้งค่า Docker ที่แสดงในที่นี้อ้างอิงจากการ deploy ในโปรดักชันที่ประมวลผลคำสั่งซื้อ Shopify จริง