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
- Why Server-Side Tracking Is Necessary
- Architecture Overview
- Prerequisites
- Step 1: Reddit Ads Manager Setup
- Step 2: Building the Conversion Tracker
- Step 3: Docker Configuration
- Step 4: Nginx Reverse Proxy
- Step 5: SSL Certificate
- Step 6: Shopify Webhook Configuration
- Step 7: Testing and Verification
- Troubleshooting Common Issues
- 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
| Component | Purpose | Technology |
|---|---|---|
| Webhook Receiver | Receives Shopify order events | Node.js/Express |
| HMAC Verification | Validates webhook authenticity | crypto (SHA-256) |
| Event Transformer | Converts Shopify data to Reddit format | Custom service |
| Deduplication | Prevents duplicate events | PostgreSQL |
| Reddit API Client | Sends events to Reddit | HTTP client with retry logic |
| Reverse Proxy | SSL termination, routing | Nginx |
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
- Go to Reddit Ads Manager → Events Manager
- Click “Create New Pixel”
- Name your pixel (e.g., “Shopify Store Conversions”)
- Copy the Pixel ID (format:
t2_abc123)
Generate Conversions API Access Token
- In Events Manager, click on your pixel
- Go to “Settings” → “Conversions API”
- Click “Generate Access Token”
- Copy the token (it starts with
Bearer ey...) - 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
- Go to Shopify Admin → Settings → Notifications
- Scroll down to “Webhooks”
- 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
- In Shopify Admin, go to the webhook you created
- Click “Send test notification”
- 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
- Go to Reddit Ads Manager → Events Manager
- Click on your pixel
- Go to “Test Events” tab
- 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:
| Error | Cause | Solution |
|---|---|---|
| “unexpected type number” | value sent as decimal instead of integer | Verify you’re multiplying by 100 and using Math.round() |
| “unknown field event_id” | Sending event_id in the Reddit payload | Remove event_id from redditEvent. Use conversion_id instead |
| “missing required field” | Missing event_at, event_type, or user data | Check your transformer is setting all required fields |
| 401 Unauthorized | Invalid access token | Regenerate token in Reddit Events Manager |
| 429 Too Many Requests | Rate limiting | Wait 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.