tva
← Insights

ShopifyのためのRedditピクセルトラッキング:Conversions APIによるサーバーサイド実装

Redditのeコマース向けトラッキングは、他の広告プラットフォームと同じ課題に直面しています。ブラウザベースのピクセルは、広告ブロッカー、プライバシー設定、iOSのトラッキング制限によってますますブロックされています。Redditの公式ドキュメントによると、Conversions APIは“より耐障害性の高い”トラッキングを提供し、“広告ブロッカーやブラウザの制限の影響を受けにくい”とされています。

このガイドでは、Reddit Conversions APIを使用してShopifyストアのサーバーサイドRedditコンバージョントラッキングを実装する方法をご紹介します。サーバーからRedditに直接コンバージョンデータを送信し、クライアントサイドのすべての制限を回避する、本番環境対応のDockerベースのソリューションが構築できます。

Redditは公式にPixelとConversions APIの両方を併用することを推奨しています。ドキュメントには次のように記載されています:“最良の結果を得るためには、Reddit PixelとConversions APIの両方を統合することをお勧めします。CAPIはより正確なデータを取得し、コンバージョンカバレッジを向上させるのに役立ちます。”


目次

  1. サーバーサイドトラッキングが必要な理由
  2. アーキテクチャ概要
  3. 前提条件
  4. ステップ1:Reddit Ads Managerの設定
  5. ステップ2:コンバージョントラッカーの構築
  6. ステップ3:Docker設定
  7. ステップ4:Nginxリバースプロキシ
  8. ステップ5:SSL証明書
  9. ステップ6:Shopify Webhookの設定
  10. ステップ7:テストと検証
  11. よくある問題のトラブルシューティング
  12. モニタリングとメンテナンス

サーバーサイドトラッキングが必要な理由

クライアントサイドピクセルの限界

Reddit Pixelはユーザーのブラウザで実行されるJavaScriptスニペットです。基本的なトラッキングには機能しますが、2025年現在、大きな制限に直面しています:

  • 広告ブロッカー: ブラウザ拡張機能がピクセルリクエストを完全にブロックします
  • iOS App Tracking Transparency: 明示的なユーザーの同意が必要です
  • サードパーティCookieのブロック: SafariとFirefoxがデフォルトでブロックします
  • プライバシー設定: 最新のブラウザがトラッキング機能を制限します

これらは理論上の問題ではありません。業界データによると、クライアントサイドトラッキングのみでは、コンバージョンイベントの30〜50%が失われる可能性があります。

Redditの公式推奨

RedditのConversions APIドキュメント(出典)より:

“CAPIはサーバーサイドで動作するため、シグナルロスに対してより耐障害性が高く、広告ブロッカーやブラウザの制限の影響を受けにくくなります。これにより、計測、ターゲティング、最適化が改善されます。”

Conversions APIはサーバー間でデータを送信します。ブラウザが関与しないため、ブロックやユーザー同意のプロンプトがなく、完全なデータ精度が実現されます。

両方が必要な理由

RedditはPixelとCAPIの併用を推奨しています:

  • Pixel: クライアントサイドのインタラクション、クリックID、ブラウザコンテキストをキャプチャします
  • CAPI: Pixelがブロックされた場合でもコンバージョンデータがRedditに到達することを保証します
  • 重複排除: 両方が同じevent_idを送信する場合、Redditは自動的に重複イベントを処理します

このガイドではCAPIコンポーネントを実装します。Pixelは、Shopifyのカスタマーイベントを通じて別途追加できます。


アーキテクチャ概要

仕組み

この実装では、Shopify Webhookを使用して注文イベントをサーバーサイドでキャプチャし、RedditのConversions APIに転送します:

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レシーバーShopifyの注文イベントを受信しますNode.js/Express
HMAC検証Webhookの真正性を検証しますcrypto (SHA-256)
イベントトランスフォーマーShopifyデータをReddit形式に変換しますカスタムサービス
重複排除重複イベントを防止しますPostgreSQL
Reddit APIクライアントイベントをRedditに送信しますリトライロジック付きHTTPクライアント
リバースプロキシSSL終端、ルーティングNginx

前提条件

必要なもの:

  • サーバー: Dockerがインストールされたlinux VPS(最低2GB RAM)
  • ドメイン: サーバーを指すサブドメイン(例:shopify-events.yourdomain.com
  • Reddit Adsアカウント: Pixelが作成されたアクティブなアカウント
  • Shopifyストア: Webhookを設定するための管理者アクセス
  • ツール: SSHアクセス、基本的なコマンドラインの知識

ステップ1:Reddit Ads Managerの設定

Pixelの作成

  1. Reddit Ads Manager → Events Managerに移動します
  2. “Create New Pixel”をクリックします
  3. Pixelに名前を付けます(例:“Shopify Store Conversions”)
  4. Pixel IDをコピーします(形式:t2_abc123

Conversions APIアクセストークンの生成

  1. Events Managerで、Pixelをクリックします
  2. “Settings” → “Conversions API”に移動します
  3. “Generate Access Token”をクリックします
  4. トークンをコピーします(Bearer ey...で始まります)
  5. 重要: このトークンを安全に保存してください。再度表示されることはありません。

これで以下が揃いました:

  • REDDIT_PIXEL_ID(例:t2_abc123
  • REDDIT_ACCESS_TOKEN(例:eyJhbGciOiJSUzI1NiIsImtpZCI...

ステップ2:コンバージョントラッカーの構築

プロジェクトのディレクトリ構造を作成します:

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

このミドルウェアはHMAC検証とリクエストボディの解析を処理します:

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,
  };
}

重要な詳細: RedditのAPIは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リバースプロキシ

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

SSLを使用するようにNginx設定を更新します:

# 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証明書
  • {"status":"healthy"}を含むJSONレスポンス

ステップ6:Shopify Webhookの設定

アプリケーションの起動

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

ログを確認します:

docker compose logs -f reddit-capi

以下が表示されるはずです:Server running on port 3000

ShopifyでWebhookを設定する

  1. Shopify管理画面 → 設定 → 通知に移動します
  2. “Webhooks”までスクロールダウンします
  3. “Create webhook”をクリックします

注文作成Webhook:

  • イベント: Order creation
  • 形式: JSON
  • URL: https://shopify-events.yourdomain.com/webhooks/shopify
  • APIバージョン: 最新版(2025-01以降)

“Save webhook”をクリックします

Webhook署名シークレットのコピー:

Webhookを作成すると、Shopifyが署名シークレットを表示します。それをコピーして.envファイルに追加してください:

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

新しいシークレットを適用するためにコンテナを再起動します:

docker compose restart reddit-capi

オプション:チェックアウト作成Webhook

Redditで放棄されたカートを“Lead”イベントとしてトラッキングしたい場合は、“Checkout creation”イベントについても同じ手順を繰り返してください。


ステップ7:テストと検証

有効な署名なしでHMACをテストする

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

期待されるレスポンス:

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

これでセキュリティが機能していることが確認できます。

Shopifyからテストwebhookを送信する

  1. Shopify管理画面で、作成したWebhookに移動します
  2. “Send test notification”をクリックします
  3. ログを確認します:
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. Pixelをクリックします
  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'で表示されるはずです。

実際のテスト注文を作成する

Shopifyの“Test order”機能を使用するか、開発ストアで実際の注文を作成してください。Webhookが自動的に発火し、15〜30分以内にRedditでコンバージョンが確認できます。


よくある問題のトラブルシューティング

問題:イベントがRedditに表示されない

ログでReddit APIエラーを確認します:

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

よくあるエラー:

エラー原因解決策
“unexpected type number”valueが整数ではなく小数で送信されている100を掛けてMath.round()を使用していることを確認してください
“unknown field event_id”Redditペイロードにevent_idを送信しているredditEventからevent_idを削除してください。代わりにconversion_idを使用してください
“missing required field”event_atevent_type、またはユーザーデータが欠落しているトランスフォーマーがすべての必須フィールドを設定しているか確認してください
401 Unauthorizedアクセストークンが無効Reddit Events Managerでトークンを再生成してください
429 Too Many Requestsレート制限retry-after期間を待ちます(自動的に処理されます)

問題:Shopify Webhookの失敗

Shopify Webhookのステータスを確認します:

Shopify管理画面 → 設定 → 通知 → Webhooksで、Webhookにエラーが表示されていないか確認してください。

よくある問題:

  • HMAC検証の失敗: シークレットの不一致です。Shopifyから正しいシークレットをコピーして.envを更新してください
  • タイムアウト: サーバーが5秒以内に応答していません。コンテナが実行中で正常であるか確認してください
  • SSLエラー: 証明書が無効です。curl -vI https://your-domain.com/healthで確認してください

問題:“stream is not readable”

このエラーはrawBodyCaptureexpress.json()の両方がリクエストボディを読み込もうとする場合に発生します。

解決策: Webhookルートからexpress.json()ミドルウェアを削除してください。rawBodyCapture関数がraw body取得とJSON解析の両方を処理します。

誤り:

app.use('/webhooks/shopify', express.json());  // ❌ これは使用しないでください
app.use('/webhooks/shopify', rawBodyCapture);

正解:

app.use('/webhooks/shopify', rawBodyCapture);  // ✅ 両方を処理します

問題:コンバージョンの重複

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

モニタリングとメンテナンス

ログモニタリング

リアルタイムログ:

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ジョブを設定します:

*/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

コンテナの更新

コード変更後の更新:

docker compose down
docker compose up -d --build

コンテナのステータスを表示:

docker compose ps

再ビルドなしで再起動:

docker compose restart reddit-capi

高度な設定

カスタムイベントタイプ

Redditは追加の標準イベントをサポートしています。トランスフォーマーを拡張して、より多くの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: {
  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),
  })),
},

多通貨対応

この実装はShopifyのorder.currencyフィールドを通じて多通貨に既に対応しています。常に正しく最小通貨単位に変換していることを確認してください:

  • USD、EUR、CAD: 100を掛けます(セント)
  • JPY、KRW: 乗算は不要です(既に最小単位です)
  • BHD、KWD: 1000を掛けます(小数点以下3桁)

小数点以下の桁数が異なる通貨の場合、トランスフォーマーを更新してください:

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))

次のステップ

この実装は、Reddit Adsのための本番環境対応のサーバーサイドコンバージョントラッキングを提供します。アーキテクチャはHMAC検証、重複排除、リトライロジック、包括的なロギングにより、信頼性を重視して設計されています。

この基盤を以下のように拡張できます:

  • 追加のイベントタイプ: 商品閲覧、カート放棄、検索
  • 強化されたユーザーマッチング: URLパラメータからのRedditクリックIDの取り込み
  • アラート: 失敗したイベント用の監視ツールとの統合
  • 分析ダッシュボード: PostgreSQLからのコンバージョンデータの可視化

参考までに、ここで示したソースコード全体とDocker設定は、実際のShopify注文を処理する本番デプロイに基づいています。