tva
← Insights

Reddit-Pixel-Tracking für Shopify: Serverseitige Implementierung mit der Conversions API

Reddits Tracking für E-Commerce steht vor denselben Herausforderungen wie andere Werbeplattformen: Browserbasierte Pixel werden zunehmend von Werbeblockern, Datenschutzeinstellungen und iOS-Tracking-Beschränkungen blockiert. Laut der offiziellen Reddit-Dokumentation bietet die Conversions API ein „widerstandsfähigeres“ Tracking, das „weniger anfällig für Werbeblocker und Browser-Einschränkungen“ ist.

Dieser Leitfaden zeigt Ihnen, wie Sie serverseitiges Reddit-Conversion-Tracking für Shopify-Shops mithilfe der Reddit Conversions API implementieren. Sie erhalten eine produktionsreife Docker-basierte Lösung, die Conversion-Daten direkt von Ihrem Server an Reddit sendet und dabei alle clientseitigen Einschränkungen umgeht.

Reddit empfiehlt offiziell die gemeinsame Nutzung von Pixel und Conversions API. Aus deren Dokumentation: „Für die besten Ergebnisse empfehlen wir die Integration sowohl des Reddit Pixels als auch der Conversions API...CAPI kann Ihnen helfen, genauere Daten zu erhalten und Ihre Conversion-Abdeckung zu erhöhen.“


Inhaltsverzeichnis

  1. Warum serverseitiges Tracking notwendig ist
  2. Architekturübersicht
  3. Voraussetzungen
  4. Schritt 1: Reddit Ads Manager einrichten
  5. Schritt 2: Den Conversion Tracker erstellen
  6. Schritt 3: Docker-Konfiguration
  7. Schritt 4: Nginx Reverse Proxy
  8. Schritt 5: SSL-Zertifikat
  9. Schritt 6: Shopify Webhook-Konfiguration
  10. Schritt 7: Testen und Verifizierung
  11. Fehlerbehebung häufiger Probleme
  12. Monitoring und Wartung

Warum serverseitiges Tracking notwendig ist

Die Einschränkungen clientseitiger Pixel

Das Reddit Pixel ist ein JavaScript-Snippet, das im Browser des Nutzers ausgeführt wird. Obwohl es für grundlegendes Tracking funktioniert, stößt es 2025 auf erhebliche Einschränkungen:

  • Werbeblocker: Browser-Erweiterungen blockieren Pixel-Anfragen vollständig
  • iOS App Tracking Transparency: Erfordert ausdrückliche Zustimmung des Nutzers
  • Blockierung von Drittanbieter-Cookies: Safari und Firefox blockieren standardmäßig
  • Datenschutzeinstellungen: Moderne Browser schränken Tracking-Fähigkeiten ein

Dies sind keine theoretischen Probleme. Laut Branchendaten können 30-50 % der Conversion-Events bei rein clientseitigem Tracking verloren gehen.

Reddits offizielle Empfehlung

Aus der Reddit Conversions API-Dokumentation (Quelle):

„CAPI ist widerstandsfähiger gegenüber Signalverlust, da es serverseitig arbeitet und damit weniger anfällig für Werbeblocker und Browser-Einschränkungen ist. Dies führt zu verbesserter Messung, Zielgruppenansprache und Optimierung.“

Die Conversions API sendet Daten Server-zu-Server. Keine Browser-Beteiligung bedeutet keine Blockierung, keine Einwilligungsaufforderungen und vollständige Datengenauigkeit.

Warum Sie beides brauchen

Reddit empfiehlt die gemeinsame Nutzung von Pixel und CAPI:

  • Pixel: Erfasst clientseitige Interaktionen, Klick-IDs, Browser-Kontext
  • CAPI: Stellt sicher, dass Conversion-Daten Reddit erreichen, auch wenn das Pixel blockiert wird
  • Deduplizierung: Reddit verarbeitet automatisch doppelte Events, wenn beide dieselbe event_id senden

Dieser Leitfaden implementiert die CAPI-Komponente. Das Pixel können Sie separat über Shopifys Customer Events hinzufügen.


Architekturübersicht

Funktionsweise

Die Implementierung nutzt Shopify-Webhooks, um Bestellereignisse serverseitig zu erfassen, und leitet sie dann an Reddits Conversions API weiter:

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)

Komponenten

KomponenteZweckTechnologie
Webhook ReceiverEmpfängt Shopify-BestellereignisseNode.js/Express
HMAC-VerifizierungValidiert die Authentizität des Webhookscrypto (SHA-256)
Event TransformerKonvertiert Shopify-Daten ins Reddit-FormatEigener Service
DeduplizierungVerhindert doppelte EventsPostgreSQL
Reddit API ClientSendet Events an RedditHTTP-Client mit Retry-Logik
Reverse ProxySSL-Terminierung, RoutingNginx

Voraussetzungen

Sie benötigen:

  • Server: Linux VPS mit installiertem Docker (mindestens 2 GB RAM)
  • Domain: Subdomain, die auf Ihren Server zeigt (z. B. shopify-events.yourdomain.com)
  • Reddit Ads-Konto: Aktives Konto mit erstelltem Pixel
  • Shopify-Shop: Admin-Zugang zur Webhook-Konfiguration
  • Tools: SSH-Zugang, grundlegende Kommandozeilenkenntnisse

Schritt 1: Reddit Ads Manager einrichten

Pixel erstellen

  1. Gehen Sie zu Reddit Ads Manager → Events Manager
  2. Klicken Sie auf „Create New Pixel“
  3. Benennen Sie Ihr Pixel (z. B. „Shopify Store Conversions“)
  4. Kopieren Sie die Pixel ID (Format: t2_abc123)

Conversions API Access Token generieren

  1. Klicken Sie im Events Manager auf Ihr Pixel
  2. Gehen Sie zu „Settings“ → „Conversions API“
  3. Klicken Sie auf „Generate Access Token“
  4. Kopieren Sie den Token (er beginnt mit Bearer ey...)
  5. Wichtig: Speichern Sie diesen Token sicher. Er wird nicht erneut angezeigt.

Sie haben jetzt:

  • REDDIT_PIXEL_ID (z. B. t2_abc123)
  • REDDIT_ACCESS_TOKEN (z. B. eyJhbGciOiJSUzI1NiIsImtpZCI...)

Schritt 2: Den Conversion Tracker erstellen

Erstellen Sie die Projektverzeichnisstruktur:

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

Diese Middleware verarbeitet die HMAC-Verifizierung und das Parsen des Request-Bodys:

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

Transformiert Shopify-Bestelldaten ins Reddit CAPI-Format:

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

Wichtiges Detail: Reddits API erwartet value als Ganzzahl, die den Betrag in kleinsten Währungseinheiten darstellt (Cent für USD, Pence für GBP usw.). Deshalb multiplizieren wir mit 100 und runden.

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

Schritt 3: Docker-Konfiguration

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

Sicherheit: Setzen Sie restriktive Berechtigungen für die .env-Datei:

chmod 600 .env

Schritt 4: Nginx Reverse Proxy

Erstellen Sie die Nginx-Konfigurationsdatei:

# /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;
    }
}

Aktivieren Sie die Site:

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

Schritt 5: SSL-Zertifikat

Installieren Sie Certbot, falls noch nicht vorhanden:

apt update
apt install certbot

Erstellen Sie das Webroot-Verzeichnis:

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

Generieren Sie das Zertifikat:

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

Aktualisieren Sie die Nginx-Konfiguration für 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;
    }
}

Testen und neu laden:

nginx -t
systemctl reload nginx

Überprüfen Sie, ob HTTPS funktioniert:

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

Sie sollten Folgendes sehen:

  • HTTP/2 200
  • Gültiges SSL-Zertifikat
  • JSON-Antwort mit {"status":"healthy"}

Schritt 6: Shopify Webhook-Konfiguration

Anwendung starten

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

Prüfen Sie die Logs:

docker compose logs -f reddit-capi

Sie sollten Folgendes sehen: Server running on port 3000

Webhooks in Shopify konfigurieren

  1. Gehen Sie zu Shopify Admin → Einstellungen → Benachrichtigungen
  2. Scrollen Sie nach unten zu „Webhooks“
  3. Klicken Sie auf „Webhook erstellen“

Webhook für Bestellerstellung:

  • Event: Bestellerstellung
  • Format: JSON
  • URL: https://shopify-events.yourdomain.com/webhooks/shopify
  • API-Version: Neueste (2025-01 oder neuer)

Klicken Sie auf „Webhook speichern“

Webhook Signing Secret kopieren:

Nach dem Erstellen des Webhooks zeigt Shopify ein Signing Secret an. Kopieren Sie es und fügen Sie es Ihrer .env-Datei hinzu:

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

Starten Sie den Container neu, um das neue Secret anzuwenden:

docker compose restart reddit-capi

Optional: Webhook für Checkout-Erstellung

Wiederholen Sie den Vorgang für „Checkout creation“-Events, wenn Sie abgebrochene Warenkörbe als „Lead“-Events in Reddit tracken möchten.


Schritt 7: Testen und Verifizierung

HMAC ohne gültige Signatur testen

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

Erwartete Antwort:

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

Dies bestätigt, dass die Sicherheit funktioniert.

Test-Webhook von Shopify senden

  1. Gehen Sie im Shopify Admin zum erstellten Webhook
  2. Klicken Sie auf „Testbenachrichtigung senden“
  3. Beobachten Sie die Logs:
docker compose logs -f reddit-capi

Sie sollten Folgendes sehen:

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}

Im Reddit Events Manager überprüfen

  1. Gehen Sie zu Reddit Ads Manager → Events Manager
  2. Klicken Sie auf Ihr Pixel
  3. Gehen Sie zum Tab „Test Events“
  4. Das Testereignis sollte innerhalb weniger Minuten erscheinen

Datenbank prüfen

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;"

Sie sollten Ihr Testereignis mit status = 'sent' sehen.

Echte Testbestellung erstellen

Verwenden Sie Shopifys Funktion „Testbestellung“ oder erstellen Sie eine echte Bestellung in Ihrem Entwicklungsshop. Der Webhook wird automatisch ausgelöst, und Sie sehen die Conversion innerhalb von 15-30 Minuten in Reddit.


Fehlerbehebung häufiger Probleme

Problem: Events erscheinen nicht in Reddit

Prüfen Sie die Logs auf Reddit API-Fehler:

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

Häufige Fehler:

FehlerUrsacheLösung
„unexpected type number“value als Dezimalzahl statt Ganzzahl gesendetÜberprüfen Sie, ob Sie mit 100 multiplizieren und Math.round() verwenden
„unknown field event_id“event_id im Reddit-Payload gesendetEntfernen Sie event_id aus redditEvent. Verwenden Sie stattdessen conversion_id
„missing required field“Fehlende event_atevent_type oder BenutzerdatenPrüfen Sie, ob Ihr Transformer alle erforderlichen Felder setzt
401 UnauthorizedUngültiger Access TokenGenerieren Sie den Token im Reddit Events Manager neu
429 Too Many RequestsRate LimitingWarten Sie die Retry-After-Periode ab (wird automatisch behandelt)

Problem: Shopify-Webhook-Fehler

Shopify-Webhook-Status prüfen:

Unter Shopify Admin → Einstellungen → Benachrichtigungen → Webhooks prüfen Sie, ob der Webhook Fehler anzeigt.

Häufige Probleme:

  • HMAC-Verifizierung fehlgeschlagen: Secret stimmt nicht überein. Kopieren Sie das korrekte Secret aus Shopify und aktualisieren Sie die .env
  • Timeout: Server antwortet nicht innerhalb von 5 Sekunden. Prüfen Sie, ob der Container läuft und gesund ist
  • SSL-Fehler: Zertifikat ungültig. Überprüfen Sie mit curl -vI https://your-domain.com/health

Problem: „stream is not readable“

Dieser Fehler tritt auf, wenn sowohl rawBodyCapture als auch express.json() versuchen, den Request-Body zu lesen.

Lösung: Entfernen Sie die express.json()-Middleware von der Webhook-Route. Die rawBodyCapture-Funktion verarbeitet sowohl die Raw-Body-Erfassung als auch das JSON-Parsing.

Falsch:

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

Richtig:

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

Problem: Doppelte Conversions

Wenn Sie sowohl Reddit Pixel als auch CAPI verwenden, stellen Sie sicher, dass Sie in beiden dieselbe conversion_id verwenden.

Pixel-Implementierung (auf der Shopify-Dankeseite):

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

Reddit dedupliziert automatisch Events mit derselben conversion_id.

Problem: Datenbankverbindung fehlgeschlagen

# 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

Monitoring und Wartung

Log-Monitoring

Echtzeit-Logs:

docker compose logs -f reddit-capi

Nach Fehlern suchen:

docker compose logs reddit-capi | grep -i error

Nach Event-Typ filtern:

docker compose logs reddit-capi | grep "Purchase"

Datenbankabfragen

Aktuelle Events:

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;"

Fehlgeschlagene Events:

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;"

Event-Statistiken:

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;"

Health Check

Richten Sie einen Cron-Job ein, um den Health-Endpunkt zu überwachen:

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

SSL-Zertifikatserneuerung

Let's Encrypt-Zertifikate sind 90 Tage gültig. Certbot sollte sie automatisch erneuern.

Erneuerung testen:

certbot renew --dry-run

Zertifikatsablauf prüfen:

certbot certificates

Container-Updates

Aktualisierung nach Codeänderungen:

docker compose down
docker compose up -d --build

Container-Status anzeigen:

docker compose ps

Neustart ohne Neubau:

docker compose restart reddit-capi

Erweiterte Konfiguration

Benutzerdefinierte Event-Typen

Reddit unterstützt zusätzliche Standard-Events. Sie können den Transformer erweitern, um weitere Shopify-Webhooks zu verarbeiten:

View Content (Produktansichten):

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

In den Warenkorb legen:

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

Produktebene-Tracking

Fügen Sie Produktdetails in die Event-Metadaten ein:

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

Mehrwährungsunterstützung

Die Implementierung verarbeitet bereits mehrere Währungen über Shopifys order.currency-Feld. Stellen Sie sicher, dass Sie den Wert stets korrekt in kleinste Währungseinheiten umrechnen:

  • USD, EUR, CAD: Mit 100 multiplizieren (Cent)
  • JPY, KRW: Keine Multiplikation nötig (bereits in kleinsten Einheiten)
  • BHD, KWD: Mit 1000 multiplizieren (3 Dezimalstellen)

Für Währungen mit unterschiedlichen Dezimalstellen aktualisieren Sie den 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))

Nächste Schritte

Diese Implementierung bietet produktionsreifes serverseitiges Conversion-Tracking für Reddit Ads. Die Architektur ist auf Zuverlässigkeit ausgelegt, mit HMAC-Verifizierung, Deduplizierung, Retry-Logik und umfassendem Logging.

Sie können diese Grundlage erweitern mit:

  • Zusätzliche Event-Typen: Produktansichten, Warenkorbabbrüche, Suchen
  • Verbessertes User Matching: Reddit Klick-IDs aus URL-Parametern einbeziehen
  • Alerting: Integration mit Monitoring-Tools für fehlgeschlagene Events
  • Analytics-Dashboard: Conversion-Daten aus PostgreSQL visualisieren

Zur Referenz: Der hier gezeigte vollständige Quellcode und die Docker-Konfiguration basieren auf einem Produktions-Deployment, das Live-Shopify-Bestellungen verarbeitet.