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 Docker-basierte Implementierung, die Conversion-Daten direkt von Ihrem Server an Reddit sendet, ohne vom Browser des Besuchers abhängig zu sein.
Reddit empfiehlt offiziell die gemeinsame Nutzung von Pixel und Conversions API. Aus der 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
- Warum serverseitiges Tracking notwendig ist
- Architekturübersicht
- Voraussetzungen
- Schritt 1: Reddit Ads Manager einrichten
- Schritt 2: Den Conversion-Tracker erstellen
- Schritt 3: Docker-Konfiguration
- Schritt 4: Nginx-Reverse-Proxy
- Schritt 5: SSL-Zertifikat
- Schritt 6: Shopify-Webhook-Konfiguration
- Schritt 7: Testen und Überprüfen
- Häufige Probleme beheben
- 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. Je nach Zielgruppe und Konfiguration kann bei rein clientseitigem Tracking ein erheblicher Anteil der Conversion-Ereignisse 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. Da die Anfrage nicht aus dem Browser kommt, können Werbeblocker und Browser-Einschränkungen sie nicht blockieren. Ihre Einwilligungspflichten entfallen dadurch jedoch nicht: Sie benötigen weiterhin eine Rechtsgrundlage, um Kundendaten an Reddit weiterzugeben, und die Daten sind nur so vollständig wie die Daten, die Ihr Shop erfasst.
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 Ereignisse, wenn beide dieselbe Conversion-ID (
conversion_idin CAPI) 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
| Komponente | Zweck | Technologie |
|---|---|---|
| Webhook-Empfänger | Empfängt Shopify-Bestellereignisse | Node.js/Express |
| HMAC-Verifizierung | Prüft die Authentizität des Webhooks | crypto (SHA-256) |
| Ereignistransformator | Konvertiert Shopify-Daten ins Reddit-Format | Eigener Dienst |
| Deduplizierung | Verhindert doppelte Ereignisse | PostgreSQL |
| Reddit-API-Client | Sendet Ereignisse an Reddit | HTTP-Client mit Wiederholungslogik |
| Reverse Proxy | SSL-Terminierung, Routing | Nginx |
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
- Gehen Sie zu Reddit Ads Manager → Events Manager
- Klicken Sie auf „Create New Pixel“
- Benennen Sie Ihr Pixel (z. B. „Shopify Store Conversions“)
- Kopieren Sie die Pixel-ID (Format:
t2_abc123)
Zugriffstoken für die Conversions API generieren
- Klicken Sie im Events Manager auf Ihr Pixel
- Gehen Sie zu „Settings“ → „Conversions API“
- Klicken Sie auf „Generate Access Token“
- Kopieren Sie den Token (er beginnt mit
Bearer ey...) - 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 Verzeichnisstruktur des Projekts:
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. Das gilt für Währungen mit zwei Dezimalstellen; Hinweise zu Währungen ohne Dezimalstellen und mit drei Dezimalstellen finden Sie unten im Abschnitt zur Unterstützung mehrerer Währungen.
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`);
lastError = new Error(`Reddit API error: 429 - ${responseText}`);
if (attempt < 3) {
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
Der Schritt npm ci benötigt eine package-lock.json. Erzeugen Sie diese vor dem Build einmalig mit npm install im Projektverzeichnis.
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 Sie die Konfiguration und laden Sie sie neu:
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
- Ein JSON-Antworttext mit
{"status":"healthy"}, wenn Sie denselben Befehl ohne-Iausführen (-Ifordert nur die Header an)
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
- Gehen Sie zu Shopify Admin → Einstellungen → Benachrichtigungen
- Scrollen Sie nach unten zu „Webhooks“
- Klicken Sie auf „Webhook erstellen“
Webhook für Bestellerstellung:
- Ereignis: Bestellerstellung
- Format: JSON
- URL:
https://shopify-events.yourdomain.com/webhooks/shopify - API-Version: Neueste (2025-01 oder neuer)
Klicken Sie auf „Webhook speichern“
Signaturschlüssel des Webhooks kopieren:
Nach dem Erstellen des Webhooks zeigt Shopify einen Signaturschlüssel 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
Erstellen Sie den Container neu, um den neuen Signaturschlüssel anzuwenden (docker compose restart lädt geänderte .env-Werte nicht neu):
docker compose up -d reddit-capi
Optional: Webhook für Checkout-Erstellung
Wiederholen Sie den Vorgang für Ereignisse vom Typ „Checkout creation“, wenn Sie Checkout-Starts als „Lead“-Ereignisse an Reddit senden möchten. Die Erstellung eines Checkouts allein bedeutet nicht, dass ein Warenkorb abgebrochen wurde.
Schritt 7: Testen und Überprüfen
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
- Gehen Sie im Shopify Admin zum erstellten Webhook
- Klicken Sie auf „Testbenachrichtigung senden“
- 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
- Gehen Sie zu Reddit Ads Manager → Events Manager
- Klicken Sie auf Ihr Pixel
- Gehen Sie zum Tab „Test Events“
- 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.
Häufige Probleme beheben
Problem: Ereignisse 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:
| Fehler | Ursache | Lö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 gesendet | Entfernen Sie event_id aus redditEvent. Verwenden Sie stattdessen conversion_id |
| „missing required field“ | Fehlende event_at, event_type oder Benutzerdaten | Prüfen Sie, ob Ihr Transformationscode alle erforderlichen Felder setzt |
| 401 Unauthorized | Ungültiger Zugriffstoken | Generieren Sie den Token im Reddit Events Manager neu |
| 429 Too Many Requests | Aufruflimit erreicht | Warten Sie die durch Retry-After vorgegebene Dauer 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: Signaturschlüssel stimmt nicht überein. Kopieren Sie den korrekten Signaturschlüssel 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 das 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', {
conversionId: '{{ order.id }}', // Must match conversion_id in CAPI
value: {{ order.total_price }},
currency: '{{ order.currency }}'
});
Reddit dedupliziert automatisch Ereignisse 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 Ereignistyp filtern:
docker compose logs reddit-capi | grep "Purchase"
Datenbankabfragen
Aktuelle Ereignisse:
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 Ereignisse:
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;"
Ereignisstatistiken:
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;"
Zustandsprüfung
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" [email protected]
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 Ereignistypen
Reddit unterstützt zusätzliche Standardereignisse. Sie können den Transformationscode 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;
Tracking auf Produktebene
Fügen Sie Produktdetails in die Ereignismetadaten 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 Transformationscode:
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 serverseitiges Conversion-Tracking für Reddit Ads mit HMAC-Verifizierung, Deduplizierung, Wiederholungslogik und Logging. Bevor Sie sich im Produktivbetrieb darauf verlassen, schließen Sie drei Lücken im Beispielcode: Bilden Sie die lokale event_id allein aus der Shopify-ID (durch das Suffix Date.now() erhält jede erneute Zustellung von Shopify eine neue ID, sodass die Duplikatprüfung nie eine Übereinstimmung findet), ergänzen Sie einen Job, der als failed markierte Ereignisse erneut verarbeitet (der Webhook gibt 200 zurück, daher versucht Shopify die Zustellung nicht erneut), und vergleichen Sie HMACs mit crypto.timingSafeEqual statt mit !==.
Sie können diese Grundlage erweitern mit:
- Zusätzliche Ereignistypen: Produktansichten, Warenkorbabbrüche, Suchen
- Verbesserter Nutzerabgleich: Reddit-Klick-IDs aus URL-Parametern einbeziehen
- Benachrichtigungen: Integration mit Monitoring-Tools für fehlgeschlagene Ereignisse
- Analyse-Dashboard: Conversion-Daten aus PostgreSQL visualisieren
Zur Referenz: Der hier gezeigte vollständige Quellcode und die Docker-Konfiguration basieren auf einem Deployment im Produktivbetrieb, das echte Shopify-Bestellungen verarbeitet.