tva
← Approfondimenti

Tracciamento Reddit Pixel per Shopify: implementazione lato server con Conversions API

Il tracciamento di Reddit per l'e-commerce affronta le stesse sfide delle altre piattaforme pubblicitarie: i pixel basati sul browser vengono sempre più bloccati da strumenti di blocco della pubblicità, impostazioni sulla privacy e restrizioni al tracciamento di iOS. Secondo la documentazione ufficiale di Reddit, la Conversions API fornisce un tracciamento «più resiliente» che è «meno soggetto agli strumenti di blocco della pubblicità e alle restrizioni del browser».

Questa guida ti mostra come implementare il tracciamento lato server delle conversioni di Reddit per i negozi Shopify utilizzando la Reddit Conversions API. Otterrai un’implementazione basata su Docker che invia i dati di conversione direttamente dal tuo server a Reddit, senza dipendere dal browser del visitatore.

Reddit raccomanda ufficialmente di utilizzare insieme il Pixel e la Conversions API. Dalla sua documentazione: «Per i migliori risultati, raccomandiamo di integrare sia il Reddit Pixel che la Conversions API…CAPI può aiutarti a ottenere dati più accurati e aumentare la copertura delle conversioni».


Indice

  1. Perché il tracciamento lato server è necessario
  2. Panoramica dell'architettura
  3. Prerequisiti
  4. Passo 1: configurazione di Reddit Ads Manager
  5. Passo 2: creazione del sistema di tracciamento delle conversioni
  6. Passo 3: configurazione Docker
  7. Passo 4: reverse proxy Nginx
  8. Passo 5: certificato SSL
  9. Passo 6: configurazione dei webhook di Shopify
  10. Passo 7: test e verifica
  11. Risoluzione dei problemi comuni
  12. Monitoraggio e manutenzione

Perché il tracciamento lato server è necessario

La limitazione del Pixel lato client

Il Reddit Pixel è uno snippet JavaScript che viene eseguito nel browser dell'utente. Sebbene funzioni per il tracciamento di base, nel 2025 affronta limitazioni significative:

  • Blocco della pubblicità: Le estensioni del browser bloccano completamente le richieste del pixel
  • iOS App Tracking Transparency: Richiede il consenso esplicito dell'utente
  • Blocco dei cookie di terze parti: Safari e Firefox li bloccano per impostazione predefinita
  • Impostazioni sulla privacy: I browser moderni limitano le capacità di tracciamento

Non si tratta di problemi teorici. A seconda del pubblico e della configurazione, una quota significativa degli eventi di conversione può andare persa con il solo tracciamento lato client.

La raccomandazione ufficiale di Reddit

Dalla documentazione della Conversions API di Reddit (fonte):

«CAPI è più resiliente alla perdita di segnale perché opera lato server, rendendola meno soggetta agli strumenti di blocco della pubblicità e alle restrizioni del browser. Questo migliora la misurazione, il targeting e l’ottimizzazione».

La Conversions API invia i dati da server a server. Poiché la richiesta non proviene dal browser, i blocchi degli annunci e le restrizioni del browser non possono bloccarla. Questo non elimina però gli obblighi relativi al consenso: serve comunque una base giuridica per condividere i dati dei clienti con Reddit, e la completezza dei dati dipende da ciò che il tuo negozio raccoglie.

Perché servono entrambi

Reddit raccomanda di utilizzare insieme il Pixel e CAPI:

  • Pixel: Cattura le interazioni lato client, gli ID di clic, il contesto del browser
  • CAPI: Assicura che i dati di conversione raggiungano Reddit anche quando il Pixel è bloccato
  • Deduplicazione: Reddit gestisce automaticamente gli eventi duplicati quando entrambi inviano lo stesso ID di conversione (conversion_id in CAPI)

Questa guida implementa il componente CAPI. Puoi aggiungere il Pixel separatamente tramite gli eventi dei clienti di Shopify.


Panoramica dell'architettura

Come funziona

L'implementazione utilizza i webhook di Shopify per catturare gli eventi degli ordini lato server, poi li inoltra alla Conversions API di Reddit:

Shopify Store (ordine creato)
    |
    v
Shopify Webhook (HTTPS POST)
    |
    v
Nginx Reverse Proxy (terminazione SSL)
    |
    v
Node.js Application (container Docker)
    |
    v
1. Verifica HMAC (sicurezza)
2. Trasformazione evento (formato Shopify -> formato Reddit)
3. Hashing PII (SHA-256)
4. Controllo deduplicazione (PostgreSQL)
5. Invio a Reddit CAPI
6. Log del risultato
    |
    v
Reddit Ads Platform (conversione attribuita)

Componenti

ComponenteScopoTecnologia
Ricevitore dei webhookRiceve gli eventi degli ordini di ShopifyNode.js/Express
Verifica HMACValida l'autenticità del webhookcrypto (SHA-256)
Trasformatore degli eventiConverte i dati Shopify nel formato RedditServizio personalizzato
DeduplicazionePreviene eventi duplicatiPostgreSQL
Client API di RedditInvia eventi a RedditClient HTTP con logica dei nuovi tentativi
Reverse proxyTerminazione SSL, instradamentoNginx

Prerequisiti

Ti serviranno:

  • Server: VPS Linux con Docker installato (minimo 2GB RAM)
  • Dominio: Sottodominio che punta al tuo server (es. shopify-events.yourdomain.com)
  • Account Reddit Ads: Account attivo con Pixel creato
  • Negozio Shopify: Accesso amministrativo per configurare i webhook
  • Strumenti: Accesso SSH, conoscenza base della riga di comando

Passo 1: configurazione di Reddit Ads Manager

Crea il Pixel

  1. Vai su Reddit Ads Manager → Events Manager
  2. Fai clic su «Create New Pixel»
  3. Dai un nome al pixel (es. «Shopify Store Conversions»)
  4. Copia il Pixel ID (formato: t2_abc123)

Genera il token di accesso alla Conversions API

  1. In Events Manager, fai clic sul tuo pixel
  2. Vai su «Settings» → «Conversions API»
  3. Fai clic su «Generate Access Token»
  4. Copia il token (inizia con Bearer ey...)
  5. Importante: Conserva questo token in modo sicuro. Non potrai visualizzarlo di nuovo.

Ora hai:

  • REDDIT_PIXEL_ID (es. t2_abc123)
  • REDDIT_ACCESS_TOKEN (es. eyJhbGciOiJSUzI1NiIsImtpZCI...)

Passo 2: creazione del sistema di tracciamento delle conversioni

Crea la struttura della directory del progetto:

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

Questo middleware gestisce la verifica HMAC e il parsing del corpo della richiesta:

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

Trasforma i dati dell'ordine Shopify nel formato Reddit CAPI:

import { v4 as uuidv4 } from 'uuid';
import { hashEmail, hashPhone } from './crypto.js';

export function transformOrderToPurchase(order) {
  const eventId = `shopify_order_${order.id}_${Date.now()}`;

  const redditEvent = {
    event_at: order.created_at,
    event_type: {
      tracking_type: 'Purchase',
    },
    user: {
      // Hash PII for privacy
      email: hashEmail(order.email),
      ...(order.phone && { phone_number: hashPhone(order.phone) }),

      // IP and User Agent if available
      ...(order.browser_ip && { ip_address: order.browser_ip }),
      ...(order.client_details?.user_agent && {
        user_agent: order.client_details.user_agent
      }),
    },
    event_metadata: {
      // Reddit requires value as integer in minor currency units (cents)
      value: Math.round((parseFloat(order.total_price) || 0) * 100),

      // Unique ID for deduplication
      conversion_id: String(order.id),

      currency: order.currency,

      // Optional: Product details
      item_count: order.line_items?.length || 0,

      // Custom data for tracking
      order_number: order.order_number,
    },
  };

  return {
    event_id: eventId,
    event_type: 'Purchase',
    shopify_id: String(order.id),
    reddit_payload: redditEvent,
  };
}

export function transformCheckoutToLead(checkout) {
  const eventId = `shopify_checkout_${checkout.id}_${Date.now()}`;

  const redditEvent = {
    event_at: checkout.created_at,
    event_type: {
      tracking_type: 'Lead',
    },
    user: {
      email: hashEmail(checkout.email),
      ...(checkout.phone && { phone_number: hashPhone(checkout.phone) }),
    },
    event_metadata: {
      value: Math.round((parseFloat(checkout.total_price) || 0) * 100),
      conversion_id: String(checkout.id),
      currency: checkout.currency,
    },
  };

  return {
    event_id: eventId,
    event_type: 'Lead',
    shopify_id: String(checkout.id),
    reddit_payload: redditEvent,
  };
}

Dettaglio critico: L'API di Reddit si aspetta il value come intero che rappresenta l'importo in unità minori della valuta (centesimi per USD, pence per GBP, ecc.). Per questo moltiplichiamo per 100 e arrotondiamo. Questo è corretto per le valute con due decimali; per quelle senza decimali o con tre decimali, consulta la sezione Supporto multivaluta più avanti.

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

Passo 3: configurazione Docker

Dockerfile

Il passaggio npm ci richiede un file package-lock.json. Generalo una volta con npm install nella directory del progetto prima di creare l’immagine.

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

Sicurezza: Imposta permessi restrittivi sul file .env:

chmod 600 .env

Passo 4: reverse proxy Nginx

Crea il file di configurazione 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;
    }
}

Abilita il sito:

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

Passo 5: certificato SSL

Installa Certbot se non è già installato:

apt update
apt install certbot

Crea la directory webroot:

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

Genera il certificato:

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

Aggiorna la configurazione Nginx per utilizzare 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;
    }
}

Testa e ricarica:

nginx -t
systemctl reload nginx

Verifica che HTTPS funzioni:

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

Dovresti visualizzare:

  • HTTP/2 200
  • certificato SSL valido
  • Un corpo JSON con {"status":"healthy"} quando esegui lo stesso comando senza -I (-I richiede solo gli header)

Passo 6: configurazione dei webhook di Shopify

Avvia l'applicazione

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

Controlla i log:

docker compose logs -f reddit-capi

Dovresti visualizzare: Server running on port 3000

Configura i webhook in Shopify

  1. Vai su Shopify Admin → Settings → Notifications
  2. Scorri fino a «Webhooks»
  3. Fai clic su «Create webhook»

Webhook di creazione ordine:

  • Evento: Order creation
  • Formato: JSON
  • URL: https://shopify-events.yourdomain.com/webhooks/shopify
  • Versione API: Ultima (2025-01 o più recente)

Fai clic su «Save webhook»

Copia il segreto di firma del webhook:

Dopo aver creato il webhook, Shopify mostra un segreto di firma. Copialo e aggiungilo al tuo file .env:

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

Ricrea il container per applicare il nuovo segreto (docker compose restart non ricarica i valori modificati di .env):

docker compose up -d reddit-capi

Opzionale: webhook di creazione del checkout

Ripeti il processo per gli eventi «Checkout creation» se vuoi inviare gli avvii del checkout come eventi «Lead» a Reddit. La sola creazione di un checkout non significa che un carrello sia stato abbandonato.


Passo 7: test e verifica

Test HMAC senza firma valida

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

Risposta attesa:

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

Questo conferma che la sicurezza funziona.

Invia un webhook di test da Shopify

  1. In Shopify Admin, vai al webhook creato
  2. Fai clic su «Send test notification»
  3. Monitora i log:
docker compose logs -f reddit-capi

Dovresti visualizzare:

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}

Verifica in Reddit Events Manager

  1. Vai su Reddit Ads Manager → Events Manager
  2. Fai clic sul tuo pixel
  3. Vai alla scheda «Test Events»
  4. L'evento di test dovrebbe apparire entro pochi minuti

Controlla il database

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

Dovresti vedere il tuo evento di test con status = 'sent'.

Crea un ordine di test reale

Usa la funzione «Test order» di Shopify o crea un ordine reale nel tuo negozio di sviluppo. Il webhook si attiverà automaticamente e vedrai la conversione in Reddit entro 15-30 minuti.


Risoluzione dei problemi comuni

Problema: gli eventi non appaiono in Reddit

Controlla i log per errori dell'API Reddit:

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

Errori comuni:

ErroreCausaSoluzione
«unexpected type number»value inviato come decimale invece che come interoVerifica di moltiplicare per 100 e usare Math.round()
«unknown field event_id»Invio di event_id nel payload RedditRimuovi event_id da redditEvent. Usa conversion_id invece
«missing required field»Mancano event_at, event_type o dati utenteVerifica che il trasformatore imposti tutti i campi obbligatori
401 UnauthorizedToken di accesso non validoRigenera il token in Reddit Events Manager
429 Too Many RequestsLimite di frequenza delle richiesteAttendi il periodo indicato da retry-after (gestito automaticamente)

Problema: errori dei webhook di Shopify

Controlla lo stato del webhook Shopify:

In Shopify Admin → Settings → Notifications → Webhooks, controlla se il webhook mostra errori.

Problemi comuni:

  • Verifica HMAC fallita: Il segreto non corrisponde. Copia il segreto corretto da Shopify e aggiorna il file .env
  • Timeout: Il server non risponde entro 5 secondi. Verifica se il container è in esecuzione e funzionante
  • Errori SSL: Certificato non valido. Verifica con curl -vI https://your-domain.com/health

Problema: «stream is not readable»

Questo errore si verifica quando sia rawBodyCapture che express.json() tentano di leggere il corpo della richiesta.

Soluzione: Rimuovi il middleware express.json() dalla rotta webhook. La funzione rawBodyCapture gestisce sia la cattura del corpo grezzo che il parsing JSON.

Errato:

app.use('/webhooks/shopify', express.json());  // ❌ Non usare questo
app.use('/webhooks/shopify', rawBodyCapture);

Corretto:

app.use('/webhooks/shopify', rawBodyCapture);  // ✅ Gestisce entrambi

Problema: conversioni duplicate

Se stai usando sia il Reddit Pixel che CAPI, assicurati di usare lo stesso conversion_id in entrambi.

Implementazione Pixel (nella pagina di ringraziamento di Shopify):

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

Reddit deduplicherà automaticamente gli eventi con lo stesso conversion_id.

Problema: connessione al database fallita

# 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

Monitoraggio e manutenzione

Monitoraggio dei log

Log in tempo reale:

docker compose logs -f reddit-capi

Cerca errori:

docker compose logs reddit-capi | grep -i error

Filtra per tipo di evento:

docker compose logs reddit-capi | grep "Purchase"

Query sul database

Eventi recenti:

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

Eventi falliti:

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

Statistiche degli eventi:

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

Controllo dello stato del servizio

Configura un cron job per monitorare l’endpoint di controllo dello stato del servizio:

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

Rinnovo del certificato SSL

I certificati Let's Encrypt sono validi per 90 giorni. Certbot dovrebbe rinnovarli automaticamente.

Testa il rinnovo:

certbot renew --dry-run

Controlla la scadenza del certificato:

certbot certificates

Aggiornamenti dei container

Aggiorna dopo modifiche al codice:

docker compose down
docker compose up -d --build

Visualizza lo stato dei container:

docker compose ps

Riavvia senza ricostruire:

docker compose restart reddit-capi

Configurazione avanzata

Tipi di evento personalizzati

Reddit supporta ulteriori eventi standard. È possibile estendere il trasformatore per gestire più webhook Shopify:

View Content (visualizzazioni prodotto):

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

Add to Cart:

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

Tracciamento a livello di prodotto

Includi i dettagli del prodotto nei metadati dell'evento:

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

Supporto multivaluta

L'implementazione gestisce già più valute tramite il campo order.currency di Shopify. Assicurati di convertire sempre il valore nelle unità minori della valuta correttamente:

  • USD, EUR, CAD: Moltiplica per 100 (centesimi)
  • JPY, KRW: Nessuna moltiplicazione necessaria (già in unità minori)
  • BHD, KWD: Moltiplica per 1000 (3 cifre decimali)

Per le valute con un diverso numero di cifre decimali, aggiorna il trasformatore:

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

Prossimi passi

Questa implementazione fornisce il tracciamento delle conversioni lato server per Reddit Ads con verifica HMAC, deduplicazione, logica dei nuovi tentativi e registrazione dei log. Prima di usarla in produzione, risolvi tre lacune nel codice di esempio: genera l’event_id locale usando solo l’ID di Shopify (il suffisso Date.now() assegna un nuovo ID a ogni reinvio di Shopify, quindi il controllo dei duplicati non trova mai corrispondenze), aggiungi un processo che rielabori gli eventi contrassegnati come failed (il webhook restituisce 200, quindi Shopify non li ritenta) e confronta gli HMAC con crypto.timingSafeEqual anziché con !==.

È possibile estendere questa base con:

  • Tipi di evento aggiuntivi: Visualizzazioni prodotto, abbandono del carrello, ricerche
  • Identificazione degli utenti migliorata: Includi gli ID di clic di Reddit dai parametri URL
  • Avvisi: Integrazione con strumenti di monitoraggio per eventi falliti
  • Dashboard di analisi: Visualizzazione dei dati di conversione da PostgreSQL

Come riferimento, il codice sorgente completo e la configurazione Docker presentati qui sono basati su una messa in produzione che elabora ordini Shopify reali.

Per approfondire