Reddit Pixel Tracking for Shopify: Server-Side Implementation with Conversions API

Reddit’s tracking for e-commerce faces the same challenges as other advertising platforms: browser-based pixels are increasingly blocked by ad blockers, privacy settings, and iOS tracking restrictions. According to Reddit’s official documentation, the Conversions API provides “more resilient” tracking that is “less susceptible to ad blockers and browser restrictions.”

This guide shows you how to implement server-side Reddit conversion tracking for Shopify stores using the Reddit Conversions API. You’ll have a production-ready Docker-based solution that sends conversion data directly from your server to Reddit, bypassing all client-side limitations.

Reddit officially recommends using both the Pixel and Conversions API together. From their documentation: “For the best results, we recommend integrating both the Reddit Pixel and the Conversions API…CAPI can help you get more accurate data and increase your conversion coverage.”


Table of Contents

  1. Why Server-Side Tracking Is Necessary
  2. Architecture Overview
  3. Prerequisites
  4. Step 1: Reddit Ads Manager Setup
  5. Step 2: Building the Conversion Tracker
  6. Step 3: Docker Configuration
  7. Step 4: Nginx Reverse Proxy
  8. Step 5: SSL Certificate
  9. Step 6: Shopify Webhook Configuration
  10. Step 7: Testing and Verification
  11. Troubleshooting Common Issues
  12. Monitoring and Maintenance

Why Server-Side Tracking Is Necessary

The Client-Side Pixel Limitation

The Reddit Pixel is a JavaScript snippet that runs in the user’s browser. While it works for basic tracking, it faces significant limitations in 2025:

  • Ad Blockers: Browser extensions block pixel requests entirely
  • iOS App Tracking Transparency: Requires explicit user consent
  • Third-Party Cookie Blocking: Safari and Firefox block by default
  • Privacy Settings: Modern browsers restrict tracking capabilities

These aren’t theoretical problems. According to industry data, 30-50% of conversion events can be lost with client-side tracking alone.

Reddit’s Official Recommendation

From Reddit’s Conversions API documentation (source):

“CAPI is more resilient to signal loss because it operates server-side, making it less susceptible to ad blockers and browser restrictions. This leads to improved measurement, targeting, and optimization.”

The Conversions API sends data server-to-server. No browser involvement means no blocking, no consent prompts, and complete data accuracy.

Why You Need Both

Reddit recommends using both Pixel and CAPI together:

  • Pixel: Captures client-side interactions, click IDs, browser context
  • CAPI: Ensures conversion data reaches Reddit even when Pixel is blocked
  • Deduplication: Reddit automatically handles duplicate events when both send the same event_id

This guide implements the CAPI component. You can add the Pixel separately through Shopify’s customer events.


Architecture Overview

How It Works

The implementation uses Shopify webhooks to capture order events server-side, then forwards them to Reddit’s Conversions API:

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)

Components

ComponentPurposeTechnology
Webhook ReceiverReceives Shopify order eventsNode.js/Express
HMAC VerificationValidates webhook authenticitycrypto (SHA-256)
Event TransformerConverts Shopify data to Reddit formatCustom service
DeduplicationPrevents duplicate eventsPostgreSQL
Reddit API ClientSends events to RedditHTTP client with retry logic
Reverse ProxySSL termination, routingNginx

Prerequisites

You’ll need:

  • Server: Linux VPS with Docker installed (2GB RAM minimum)
  • Domain: Subdomain pointing to your server (e.g., shopify-events.yourdomain.com)
  • Reddit Ads Account: Active account with Pixel created
  • Shopify Store: Admin access to configure webhooks
  • Tools: SSH access, basic command line knowledge

Step 1: Reddit Ads Manager Setup

Create Your Pixel

  1. Go to Reddit Ads Manager → Events Manager
  2. Click “Create New Pixel”
  3. Name your pixel (e.g., “Shopify Store Conversions”)
  4. Copy the Pixel ID (format: t2_abc123)

Generate Conversions API Access Token

  1. In Events Manager, click on your pixel
  2. Go to “Settings” → “Conversions API”
  3. Click “Generate Access Token”
  4. Copy the token (it starts with Bearer ey...)
  5. Important: Save this token securely. You won’t see it again.

You now have:

  • REDDIT_PIXEL_ID (e.g., t2_abc123)
  • REDDIT_ACCESS_TOKEN (e.g., eyJhbGciOiJSUzI1NiIsImtpZCI...)

Step 2: Building the Conversion Tracker

Create the project directory structure:

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

This middleware handles HMAC verification and request body parsing:

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

Transforms Shopify order data into Reddit CAPI format:

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

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

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

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

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

      currency: order.currency,

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

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

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

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

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

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

Critical Detail: Reddit’s API expects value as an integer representing the amount in minor currency units (cents for USD, pence for GBP, etc.). This is why we multiply by 100 and round.

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

Step 3: Docker Configuration

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

Security: Set restrictive permissions on the .env file:

chmod 600 .env

Step 4: Nginx Reverse Proxy

Create the Nginx configuration file:

# /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;
    }
}

Enable the site:

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

Step 5: SSL Certificate

Install Certbot if not already installed:

apt update
apt install certbot

Create the webroot directory:

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

Generate the certificate:

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

Update the Nginx configuration to use 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;
    }
}

Test and reload:

nginx -t
systemctl reload nginx

Verify HTTPS works:

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

You should see:

  • HTTP/2 200
  • Valid SSL certificate
  • JSON response with {"status":"healthy"}

Step 6: Shopify Webhook Configuration

Start the Application

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

Check the logs:

docker compose logs -f reddit-capi

You should see: Server running on port 3000

Configure Webhooks in Shopify

  1. Go to Shopify Admin → Settings → Notifications
  2. Scroll down to “Webhooks”
  3. Click “Create webhook”

Order Creation Webhook:

  • Event: Order creation
  • Format: JSON
  • URL: https://shopify-events.yourdomain.com/webhooks/shopify
  • API version: Latest (2025-01 or newer)

Click “Save webhook”

Copy the Webhook Signing Secret:

After creating the webhook, Shopify displays a signing secret. Copy it and add to your .env file:

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

Restart the container to apply the new secret:

docker compose restart reddit-capi

Optional: Checkout Creation Webhook

Repeat the process for “Checkout creation” events if you want to track abandoned carts as “Lead” events in Reddit.


Step 7: Testing and Verification

Test HMAC Without Valid Signature

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

Expected response:

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

This confirms security is working.

Send Test Webhook from Shopify

  1. In Shopify Admin, go to the webhook you created
  2. Click “Send test notification”
  3. Watch the logs:
docker compose logs -f reddit-capi

You should see:

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}

Verify in Reddit Events Manager

  1. Go to Reddit Ads Manager → Events Manager
  2. Click on your pixel
  3. Go to “Test Events” tab
  4. You should see the test event appear within a few minutes

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

You should see your test event with status = 'sent'.

Create Real Test Order

Use Shopify’s “Test order” feature or create a real order in your development store. The webhook will fire automatically, and you’ll see the conversion in Reddit within 15-30 minutes.


Troubleshooting Common Issues

Problem: Events Not Appearing in Reddit

Check the logs for Reddit API errors:

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

Common errors:

ErrorCauseSolution
“unexpected type number”value sent as decimal instead of integerVerify you’re multiplying by 100 and using Math.round()
“unknown field event_id”Sending event_id in the Reddit payloadRemove event_id from redditEvent. Use conversion_id instead
“missing required field”Missing event_atevent_type, or user dataCheck your transformer is setting all required fields
401 UnauthorizedInvalid access tokenRegenerate token in Reddit Events Manager
429 Too Many RequestsRate limitingWait for retry-after period (handled automatically)

Problem: Shopify Webhook Failures

Check Shopify webhook status:

In Shopify Admin → Settings → Notifications → Webhooks, check if the webhook shows errors.

Common issues:

  • HMAC verification failed: Secret mismatch. Copy the correct secret from Shopify and update .env
  • Timeout: Server not responding within 5 seconds. Check if container is running and healthy
  • SSL errors: Certificate invalid. Verify with curl -vI https://your-domain.com/health

Problem: “stream is not readable”

This error occurs when both rawBodyCapture and express.json() try to read the request body.

Solution: Remove express.json() middleware from the webhook route. The rawBodyCapture function handles both raw body capture and JSON parsing.

Incorrect:

app.use('/webhooks/shopify', express.json());  // ❌ Don't use this
app.use('/webhooks/shopify', rawBodyCapture);

Correct:

app.use('/webhooks/shopify', rawBodyCapture);  // ✅ Handles both

Problem: Duplicate Conversions

If you’re using both Reddit Pixel and CAPI, ensure you’re using the same conversion_id in both.

Pixel implementation (in Shopify thank-you page):

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

Reddit will automatically deduplicate events with the same conversion_id.

Problem: Database Connection Failed

# Check if PostgreSQL is running
docker compose ps postgres

# Test connection
docker compose exec postgres pg_isready -U reddit_capi

# Check logs
docker compose logs postgres

Monitoring and Maintenance

Log Monitoring

Real-time logs:

docker compose logs -f reddit-capi

Search for errors:

docker compose logs reddit-capi | grep -i error

Filter by event type:

docker compose logs reddit-capi | grep "Purchase"

Database Queries

Recent events:

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

Failed events:

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

Event statistics:

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

Health Check

Set up a cron job to monitor the health endpoint:

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

SSL Certificate Renewal

Let’s Encrypt certificates are valid for 90 days. Certbot should auto-renew them.

Test renewal:

certbot renew --dry-run

Check certificate expiry:

certbot certificates

Container Updates

Update after code changes:

docker compose down
docker compose up -d --build

View container status:

docker compose ps

Restart without rebuilding:

docker compose restart reddit-capi

Advanced Configuration

Custom Event Types

Reddit supports additional standard events. You can extend the transformer to handle more Shopify webhooks:

View Content (product views):

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

Product-Level Tracking

Include product details in the event metadata:

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

Multi-Currency Support

The implementation already handles multi-currency through Shopify’s order.currency field. Ensure you’re always converting the value to minor currency units correctly:

  • USD, EUR, CAD: Multiply by 100 (cents)
  • JPY, KRW: No multiplication needed (already in minor units)
  • BHD, KWD: Multiply by 1000 (3 decimal places)

For currencies with different decimal places, update the transformer:

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

What’s Next

This implementation provides production-ready server-side conversion tracking for Reddit Ads. The architecture is designed for reliability, with HMAC verification, deduplication, retry logic, and comprehensive logging.

You can extend this foundation with:

  • Additional event types: Product views, cart abandonment, searches
  • Enhanced user matching: Include Reddit click IDs from URL parameters
  • Alerting: Integrate with monitoring tools for failed events
  • Analytics dashboard: Visualize conversion data from PostgreSQL

For reference, the complete source code and Docker configuration demonstrated here is based on a production deployment processing live Shopify orders.

Scroll to Top