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
- Perché il tracciamento lato server è necessario
- Panoramica dell'architettura
- Prerequisiti
- Passo 1: configurazione di Reddit Ads Manager
- Passo 2: creazione del sistema di tracciamento delle conversioni
- Passo 3: configurazione Docker
- Passo 4: reverse proxy Nginx
- Passo 5: certificato SSL
- Passo 6: configurazione dei webhook di Shopify
- Passo 7: test e verifica
- Risoluzione dei problemi comuni
- 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_idin 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
| Componente | Scopo | Tecnologia |
|---|---|---|
| Ricevitore dei webhook | Riceve gli eventi degli ordini di Shopify | Node.js/Express |
| Verifica HMAC | Valida l'autenticità del webhook | crypto (SHA-256) |
| Trasformatore degli eventi | Converte i dati Shopify nel formato Reddit | Servizio personalizzato |
| Deduplicazione | Previene eventi duplicati | PostgreSQL |
| Client API di Reddit | Invia eventi a Reddit | Client HTTP con logica dei nuovi tentativi |
| Reverse proxy | Terminazione SSL, instradamento | Nginx |
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
- Vai su Reddit Ads Manager → Events Manager
- Fai clic su «Create New Pixel»
- Dai un nome al pixel (es. «Shopify Store Conversions»)
- Copia il Pixel ID (formato:
t2_abc123)
Genera il token di accesso alla Conversions API
- In Events Manager, fai clic sul tuo pixel
- Vai su «Settings» → «Conversions API»
- Fai clic su «Generate Access Token»
- Copia il token (inizia con
Bearer ey...) - 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(-Irichiede 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
- Vai su Shopify Admin → Settings → Notifications
- Scorri fino a «Webhooks»
- 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
- In Shopify Admin, vai al webhook creato
- Fai clic su «Send test notification»
- 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
- Vai su Reddit Ads Manager → Events Manager
- Fai clic sul tuo pixel
- Vai alla scheda «Test Events»
- 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:
| Errore | Causa | Soluzione |
|---|---|---|
| «unexpected type number» | value inviato come decimale invece che come intero | Verifica di moltiplicare per 100 e usare Math.round() |
| «unknown field event_id» | Invio di event_id nel payload Reddit | Rimuovi event_id da redditEvent. Usa conversion_id invece |
| «missing required field» | Mancano event_at, event_type o dati utente | Verifica che il trasformatore imposti tutti i campi obbligatori |
| 401 Unauthorized | Token di accesso non valido | Rigenera il token in Reddit Events Manager |
| 429 Too Many Requests | Limite di frequenza delle richieste | Attendi 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.