Suivi pixel Reddit pour Shopify : implémentation côté serveur avec l'API Conversions
Le suivi Reddit pour le commerce électronique se heurte aux mêmes défis que les autres plateformes publicitaires : les pixels basés sur le navigateur sont de plus en plus bloqués par les bloqueurs de publicités, les paramètres de confidentialité et les restrictions de suivi d'iOS. Selon la documentation officielle de Reddit, l'API Conversions offre un suivi « plus résilient » qui est « moins susceptible d'être bloqué par les bloqueurs de publicités et les restrictions des navigateurs. »
Ce guide vous montre comment implémenter le suivi des conversions Reddit côté serveur pour les boutiques Shopify à l'aide de l'API Conversions Reddit. Vous disposerez d'une solution basée sur Docker prête pour la production qui envoie les données de conversion directement depuis votre serveur vers Reddit, contournant toutes les limitations côté client.
Reddit recommande officiellement d'utiliser à la fois le Pixel et l'API Conversions. D'après leur documentation : « Pour de meilleurs résultats, nous recommandons d'intégrer à la fois le Reddit Pixel et l'API Conversions… CAPI peut vous aider à obtenir des données plus précises et à augmenter votre couverture de conversion. »
Table des matières
- Pourquoi le suivi côté serveur est nécessaire
- Présentation de l'architecture
- Prérequis
- Étape 1 : Configuration du gestionnaire de publicités Reddit
- Étape 2 : Construction du tracker de conversions
- Étape 3 : Configuration Docker
- Étape 4 : Reverse proxy Nginx
- Étape 5 : Certificat SSL
- Étape 6 : Configuration des webhooks Shopify
- Étape 7 : Tests et vérification
- Résolution des problèmes courants
- Surveillance et maintenance
Pourquoi le suivi côté serveur est nécessaire
La limitation du pixel côté client
Le Reddit Pixel est un extrait JavaScript qui s'exécute dans le navigateur de l'utilisateur. Bien qu'il fonctionne pour le suivi de base, il est confronté à des limitations importantes en 2025 :
- Bloqueurs de publicités : les extensions de navigateur bloquent entièrement les requêtes pixel
- App Tracking Transparency d'iOS : nécessite le consentement explicite de l'utilisateur
- Blocage des cookies tiers : Safari et Firefox bloquent par défaut
- Paramètres de confidentialité : les navigateurs modernes restreignent les capacités de suivi
Ce ne sont pas des problèmes théoriques. Selon les données du secteur, 30 à 50 % des événements de conversion peuvent être perdus avec le seul suivi côté client.
La recommandation officielle de Reddit
Extrait de la documentation de l'API Conversions de Reddit (source) :
« CAPI est plus résilient face aux pertes de signal car il fonctionne côté serveur, ce qui le rend moins susceptible d'être affecté par les bloqueurs de publicités et les restrictions des navigateurs. Cela conduit à une meilleure mesure, un meilleur ciblage et une meilleure optimisation. »
L'API Conversions envoie les données de serveur à serveur. Sans intervention du navigateur, aucun blocage, aucune invite de consentement et une précision totale des données.
Pourquoi vous avez besoin des deux
Reddit recommande d'utiliser à la fois le Pixel et CAPI :
- Pixel : capture les interactions côté client, les identifiants de clic, le contexte du navigateur
- CAPI : garantit que les données de conversion parviennent à Reddit même lorsque le Pixel est bloqué
- Déduplication : Reddit gère automatiquement les événements en double lorsque les deux envoient le même
event_id
Ce guide implémente le composant CAPI. Vous pouvez ajouter le Pixel séparément via les événements clients de Shopify.
Présentation de l'architecture
Fonctionnement
L'implémentation utilise les webhooks Shopify pour capturer les événements de commande côté serveur, puis les transmet à l'API Conversions de 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)
Composants
| Composant | Rôle | Technologie |
|---|---|---|
| Récepteur de webhooks | Reçoit les événements de commande Shopify | Node.js/Express |
| Vérification HMAC | Valide l'authenticité des webhooks | crypto (SHA-256) |
| Transformateur d'événements | Convertit les données Shopify au format Reddit | Service personnalisé |
| Déduplication | Empêche les événements en double | PostgreSQL |
| Client API Reddit | Envoie les événements à Reddit | Client HTTP avec logique de retry |
| Reverse proxy | Terminaison SSL, routage | Nginx |
Prérequis
Vous aurez besoin de :
- Serveur : VPS Linux avec Docker installé (2 Go de RAM minimum)
- Domaine : sous-domaine pointant vers votre serveur (ex. :
shopify-events.votredomaine.com) - Compte Reddit Ads : compte actif avec un Pixel créé
- Boutique Shopify : accès administrateur pour configurer les webhooks
- Outils : accès SSH, connaissances de base de la ligne de commande
Étape 1 : Configuration du gestionnaire de publicités Reddit
Créer votre Pixel
- Accédez au gestionnaire de publicités Reddit → Gestionnaire d'événements
- Cliquez sur « Créer un nouveau Pixel »
- Nommez votre pixel (ex. : « Shopify Store Conversions »)
- Copiez l'identifiant du Pixel (format :
t2_abc123)
Générer un jeton d'accès à l'API Conversions
- Dans le Gestionnaire d'événements, cliquez sur votre pixel
- Allez dans « Paramètres » → « API Conversions »
- Cliquez sur « Générer un jeton d'accès »
- Copiez le jeton (il commence par
Bearer ey...) - Important : conservez ce jeton en lieu sûr. Vous ne pourrez plus le consulter.
Vous disposez maintenant de :
REDDIT_PIXEL_ID(ex. :t2_abc123)REDDIT_ACCESS_TOKEN(ex. :eyJhbGciOiJSUzI1NiIsImtpZCI...)
Étape 2 : Construction du tracker de conversions
Créez la structure du répertoire de projet :
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
Ce middleware gère la vérification HMAC et l'analyse du corps de la requête :
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
Transforme les données de commande Shopify au format CAPI Reddit :
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,
};
}
Détail critique : L'API Reddit attend la valeur value sous forme d'entier représentant le montant en unités monétaires mineures (centimes pour USD, pence pour GBP, etc.). C'est pourquoi nous multiplions par 100 et arrondissons.
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);
});
});
Étape 3 : Configuration 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
Sécurité : Définissez des permissions restrictives sur le fichier .env :
chmod 600 .env
Étape 4 : Reverse proxy Nginx
Créez le fichier de configuration 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;
}
}
Activez le site :
ln -s /etc/nginx/sites-available/shopify-events.yourdomain.com.conf /etc/nginx/sites-enabled/
nginx -t
systemctl reload nginx
Étape 5 : Certificat SSL
Installez Certbot si ce n'est pas déjà fait :
apt update
apt install certbot
Créez le répertoire webroot :
mkdir -p /var/www/html/.well-known/acme-challenge
Générez le certificat :
certbot certonly --webroot \
-w /var/www/html \
-d shopify-events.yourdomain.com
Mettez à jour la configuration Nginx pour utiliser 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;
}
}
Testez et rechargez :
nginx -t
systemctl reload nginx
Vérifiez que HTTPS fonctionne :
curl -vI https://shopify-events.yourdomain.com/health
Vous devriez voir :
- HTTP/2 200
- Certificat SSL valide
- Réponse JSON avec
{"status":"healthy"}
Étape 6 : Configuration des webhooks Shopify
Démarrer l'application
cd /opt/reddit-capi
docker compose up -d
Vérifiez les journaux :
docker compose logs -f reddit-capi
Vous devriez voir : Server running on port 3000
Configurer les webhooks dans Shopify
- Accédez à l'administration Shopify → Paramètres → Notifications
- Faites défiler jusqu'à « Webhooks »
- Cliquez sur « Créer un webhook »
Webhook de création de commande :
- Événement : Création de commande
- Format : JSON
- URL :
https://shopify-events.yourdomain.com/webhooks/shopify - Version API : Dernière (2025-01 ou ultérieure)
Cliquez sur « Enregistrer le webhook »
Copier le secret de signature du webhook :
Après la création du webhook, Shopify affiche un secret de signature. Copiez-le et ajoutez-le à votre fichier .env :
echo 'SHOPIFY_WEBHOOK_SECRET=your_actual_secret_here' >> /opt/reddit-capi/.env
Redémarrez le conteneur pour appliquer le nouveau secret :
docker compose restart reddit-capi
Facultatif : webhook de création de paiement
Répétez le processus pour les événements de « Création de paiement » si vous souhaitez suivre les paniers abandonnés comme événements « Lead » dans Reddit.
Étape 7 : Tests et vérification
Tester HMAC sans signature valide
curl https://shopify-events.yourdomain.com/webhooks/shopify
Réponse attendue :
{"error":"Unauthorized - Missing HMAC"}
Cela confirme que la sécurité fonctionne.
Envoyer un webhook de test depuis Shopify
- Dans l'administration Shopify, accédez au webhook que vous avez créé
- Cliquez sur « Envoyer une notification de test »
- Surveillez les journaux :
docker compose logs -f reddit-capi
Vous devriez voir :
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}
Vérifier dans le Gestionnaire d'événements Reddit
- Accédez au gestionnaire de publicités Reddit → Gestionnaire d'événements
- Cliquez sur votre pixel
- Allez dans l'onglet « Événements de test »
- L'événement de test devrait apparaître en quelques minutes
Vérifier la base de données
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;"
Vous devriez voir votre événement de test avec status = 'sent'.
Créer une vraie commande de test
Utilisez la fonctionnalité « Commande de test » de Shopify ou créez une vraie commande dans votre boutique de développement. Le webhook se déclenchera automatiquement, et vous verrez la conversion dans Reddit sous 15 à 30 minutes.
Résolution des problèmes courants
Problème : événements n'apparaissant pas dans Reddit
Vérifiez les journaux pour les erreurs de l'API Reddit :
docker compose logs reddit-capi | grep -i "reddit api error"
Erreurs courantes :
| Erreur | Cause | Solution |
|---|---|---|
| « unexpected type number » | value envoyé en décimal au lieu d'entier | Vérifiez que vous multipliez par 100 et utilisez Math.round() |
| « unknown field event_id » | Envoi de event_id dans la charge utile Reddit | Supprimez event_id de redditEvent. Utilisez conversion_id à la place |
| « missing required field » | Champ event_at, event_type ou données utilisateur manquants | Vérifiez que votre transformateur définit tous les champs requis |
| 401 Unauthorized | Jeton d'accès invalide | Régénérez le jeton dans le Gestionnaire d'événements Reddit |
| 429 Too Many Requests | Limitation du débit | Attendez la période retry-after (gérée automatiquement) |
Problème : échecs des webhooks Shopify
Vérifiez l'état des webhooks Shopify :
Dans l'administration Shopify → Paramètres → Notifications → Webhooks, vérifiez si le webhook affiche des erreurs.
Problèmes courants :
- Vérification HMAC échouée : secret incorrect. Copiez le bon secret depuis Shopify et mettez à jour
.env - Délai d'attente : le serveur ne répond pas dans les 5 secondes. Vérifiez que le conteneur est en cours d'exécution et en bonne santé
- Erreurs SSL : certificat invalide. Vérifiez avec
curl -vI https://votre-domaine.com/health
Problème : « stream is not readable »
Cette erreur se produit lorsque rawBodyCapture et express.json() tentent tous les deux de lire le corps de la requête.
Solution : Supprimez le middleware express.json() de la route webhook. La fonction rawBodyCapture gère à la fois la capture du corps brut et l'analyse JSON.
Incorrect :
app.use('/webhooks/shopify', express.json()); // Ne pas utiliser
app.use('/webhooks/shopify', rawBodyCapture);
Correct :
app.use('/webhooks/shopify', rawBodyCapture); // Gère les deux
Problème : conversions en double
Si vous utilisez à la fois le Reddit Pixel et CAPI, assurez-vous d'utiliser le même conversion_id dans les deux.
Implémentation du Pixel (dans la page de remerciement Shopify) :
rdt('track', 'Purchase', {
transactionId: '{{ order.id }}', // Must match conversion_id in CAPI
value: {{ order.total_price }},
currency: '{{ order.currency }}'
});
Reddit dédupliquera automatiquement les événements ayant le même conversion_id.
Problème : échec de connexion à la base de données
# 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
Surveillance et maintenance
Surveillance des journaux
Journaux en temps réel :
docker compose logs -f reddit-capi
Rechercher les erreurs :
docker compose logs reddit-capi | grep -i error
Filtrer par type d'événement :
docker compose logs reddit-capi | grep "Purchase"
Requêtes de base de données
Événements récents :
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;"
Événements échoués :
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;"
Statistiques des événements :
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;"
Vérification de l'état de santé
Configurez une tâche cron pour surveiller l'endpoint de santé :
*/5 * * * * curl -sf https://shopify-events.yourdomain.com/health > /dev/null || echo "Reddit CAPI health check failed" | mail -s "Alert" your@email.com
Renouvellement du certificat SSL
Les certificats Let's Encrypt sont valables 90 jours. Certbot devrait les renouveler automatiquement.
Tester le renouvellement :
certbot renew --dry-run
Vérifier l'expiration du certificat :
certbot certificates
Mises à jour des conteneurs
Mettre à jour après des modifications de code :
docker compose down
docker compose up -d --build
Afficher l'état des conteneurs :
docker compose ps
Redémarrer sans reconstruire :
docker compose restart reddit-capi
Configuration avancée
Types d'événements personnalisés
Reddit prend en charge des événements standard supplémentaires. Vous pouvez étendre le transformateur pour gérer davantage de webhooks Shopify :
Affichage de contenu (vues de produits) :
// 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',
},
};
}
Ajout au panier :
case 'carts/update':
eventData = transformCartUpdate(req.body);
break;
Suivi au niveau du produit
Incluez les détails du produit dans les métadonnées de l'événement :
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),
})),
},
Prise en charge multidevise
L'implémentation gère déjà la multidevise via le champ order.currency de Shopify. Assurez-vous de toujours convertir correctement la valeur en unités monétaires mineures :
- USD, EUR, CAD : multiplier par 100 (centimes)
- JPY, KRW : aucune multiplication nécessaire (déjà en unités mineures)
- BHD, KWD : multiplier par 1000 (3 décimales)
Pour les devises avec un nombre de décimales différent, mettez à jour le transformateur :
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))
Prochaines étapes
Cette implémentation fournit un suivi des conversions côté serveur prêt pour la production pour Reddit Ads. L'architecture est conçue pour la fiabilité, avec la vérification HMAC, la déduplication, la logique de retry et une journalisation complète.
Vous pouvez étendre cette base avec :
- Types d'événements supplémentaires : vues de produits, abandon de panier, recherches
- Correspondance utilisateur améliorée : inclusion des identifiants de clic Reddit depuis les paramètres URL
- Alertes : intégration avec des outils de surveillance pour les événements échoués
- Tableau de bord analytique : visualisation des données de conversion depuis PostgreSQL
Pour référence, le code source complet et la configuration Docker présentés ici sont basés sur un déploiement de production traitant des commandes Shopify en temps réel.