tva
← Insights

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

  1. Pourquoi le suivi côté serveur est nécessaire
  2. Présentation de l'architecture
  3. Prérequis
  4. Étape 1 : Configuration du gestionnaire de publicités Reddit
  5. Étape 2 : Construction du tracker de conversions
  6. Étape 3 : Configuration Docker
  7. Étape 4 : Reverse proxy Nginx
  8. Étape 5 : Certificat SSL
  9. Étape 6 : Configuration des webhooks Shopify
  10. Étape 7 : Tests et vérification
  11. Résolution des problèmes courants
  12. 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

ComposantRôleTechnologie
Récepteur de webhooksReçoit les événements de commande ShopifyNode.js/Express
Vérification HMACValide l'authenticité des webhookscrypto (SHA-256)
Transformateur d'événementsConvertit les données Shopify au format RedditService personnalisé
DéduplicationEmpêche les événements en doublePostgreSQL
Client API RedditEnvoie les événements à RedditClient HTTP avec logique de retry
Reverse proxyTerminaison SSL, routageNginx

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

  1. Accédez au gestionnaire de publicités Reddit → Gestionnaire d'événements
  2. Cliquez sur « Créer un nouveau Pixel »
  3. Nommez votre pixel (ex. : « Shopify Store Conversions »)
  4. Copiez l'identifiant du Pixel (format : t2_abc123)

Générer un jeton d'accès à l'API Conversions

  1. Dans le Gestionnaire d'événements, cliquez sur votre pixel
  2. Allez dans « Paramètres » → « API Conversions »
  3. Cliquez sur « Générer un jeton d'accès »
  4. Copiez le jeton (il commence par Bearer ey...)
  5. 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

  1. Accédez à l'administration Shopify → Paramètres → Notifications
  2. Faites défiler jusqu'à « Webhooks »
  3. 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

  1. Dans l'administration Shopify, accédez au webhook que vous avez créé
  2. Cliquez sur « Envoyer une notification de test »
  3. 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

  1. Accédez au gestionnaire de publicités Reddit → Gestionnaire d'événements
  2. Cliquez sur votre pixel
  3. Allez dans l'onglet « Événements de test »
  4. 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 :

ErreurCauseSolution
« unexpected type number »value envoyé en décimal au lieu d'entierVérifiez que vous multipliez par 100 et utilisez Math.round()
« unknown field event_id »Envoi de event_id dans la charge utile RedditSupprimez event_id de redditEvent. Utilisez conversion_id à la place
« missing required field »Champ event_atevent_type ou données utilisateur manquantsVérifiez que votre transformateur définit tous les champs requis
401 UnauthorizedJeton d'accès invalideRégénérez le jeton dans le Gestionnaire d'événements Reddit
429 Too Many RequestsLimitation du débitAttendez 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.