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: การตั้งค่า Reddit Ads Manager
- ขั้นตอนที่ 2: การสร้าง Conversion Tracker
- ขั้นตอนที่ 3: การตั้งค่า Docker
- ขั้นตอนที่ 4: Nginx Reverse Proxy
- ขั้นตอนที่ 5: ใบรับรอง SSL
- ขั้นตอนที่ 6: การตั้งค่า Shopify Webhook
- ขั้นตอนที่ 7: การทดสอบและการตรวจสอบ
- การแก้ปัญหาที่พบบ่อย
- การติดตามและการบำรุงรักษา
ทำไมการติดตามฝั่งเซิร์ฟเวอร์จึงจำเป็น
ข้อจำกัดของพิกเซลฝั่งไคลเอ็นต์
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 | รับเหตุการณ์คำสั่งซื้อจาก Shopify | Node.js/Express |
| HMAC Verification | ตรวจสอบความถูกต้องของ webhook | crypto (SHA-256) |
| Event Transformer | แปลงข้อมูล Shopify เป็นรูปแบบ Reddit | Custom service |
| Deduplication | ป้องกันเหตุการณ์ซ้ำ | PostgreSQL |
| Reddit API Client | ส่งเหตุการณ์ไปยัง Reddit | HTTP client พร้อมตรรกะ retry |
| Reverse Proxy | SSL termination, การจัดเส้นทาง | Nginx |
ข้อกำหนดเบื้องต้น
คุณต้องมี:
- เซิร์ฟเวอร์: Linux VPS ที่ติดตั้ง Docker (RAM ขั้นต่ำ 2GB)
- โดเมน: ซับโดเมนที่ชี้ไปยังเซิร์ฟเวอร์ของคุณ (เช่น
shopify-events.yourdomain.com) - บัญชี Reddit Ads: บัญชีที่ใช้งานอยู่พร้อม Pixel ที่สร้างแล้ว
- ร้าน Shopify: สิทธิ์ผู้ดูแลเพื่อตั้งค่า webhook
- เครื่องมือ: การเข้าถึง SSH, ความรู้พื้นฐานเกี่ยวกับ command line
ขั้นตอนที่ 1: การตั้งค่า Reddit Ads Manager
สร้าง Pixel ของคุณ
- ไปที่ Reddit Ads Manager → Events Manager
- คลิก “Create New Pixel”
- ตั้งชื่อพิกเซลของคุณ (เช่น “Shopify Store Conversions”)
- คัดลอก Pixel ID (รูปแบบ:
t2_abc123)
สร้าง Access Token สำหรับ Conversions API
- ใน Events Manager คลิกที่พิกเซลของคุณ
- ไปที่ “Settings” → “Conversions API”
- คลิก “Generate Access Token”
- คัดลอกโทเค็น (เริ่มต้นด้วย
Bearer ey...) - สำคัญ: เก็บโทเค็นนี้อย่างปลอดภัย คุณจะไม่เห็นมันอีกครั้ง
ตอนนี้คุณมี:
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
- ไปที่ Shopify Admin → Settings → Notifications
- เลื่อนลงไปที่ “Webhooks”
- คลิก “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
- ใน Shopify Admin ไปที่ webhook ที่คุณสร้าง
- คลิก “Send test notification”
- ดู 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
- ไปที่ Reddit Ads Manager → Events Manager
- คลิกที่พิกเซลของคุณ
- ไปที่แท็บ “Test Events”
- คุณควรเห็นเหตุการณ์ทดสอบปรากฏขึ้นภายในไม่กี่นาที
ตรวจสอบฐานข้อมูล
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 Unauthorized | Access 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 จริง