Error Scenario (Reproduction Code)
Here's the webhook handler that accidentally deleted customer data when processing Shopify order updates:
// pages/api/webhooks/shopify.js - BROKEN CODE
import { PrismaClient } from '@prisma/client';
import crypto from 'crypto';
const prisma = new PrismaClient();
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).end();
}
// Dangerous: No HMAC validation
const shopifyData = req.body;
try {
// Fatal flaw: Parsing already parsed JSON
const orderData = JSON.parse(shopifyData);
// Catastrophic: Using wrong field for ID
const orderId = orderData.order_id; // undefined!
// This deletes ALL orders when orderId is undefined
await prisma.order.deleteMany({
where: {
shopifyOrderId: orderId
}
});
// Then tries to create with undefined values
await prisma.order.create({
data: {
shopifyOrderId: orderId,
customerEmail: orderData.customer_email,
totalPrice: orderData.total
}
});
res.status(200).json({ received: true });
} catch (error) {
console.log('Webhook error:', error);
res.status(500).json({ error: 'Failed to process webhook' });
}
}
// Prisma schema that allowed the disaster
// schema.prisma
model Order {
id String @id @default(cuid())
shopifyOrderId String? // Nullable allowed undefined matches
customerEmail String?
totalPrice Float?
createdAt DateTime @default(now())
}
The terminal output when this code runs shows the catastrophe unfolding:
$ npm run dev
> webhook-app@1.0.0 dev
> next dev
POST /api/webhooks/shopify 200 in 145ms
Webhook error: SyntaxError: Unexpected token o in JSON at position 1
POST /api/webhooks/shopify 200 in 89ms
Database query: DELETE FROM "Order" WHERE "shopifyOrderId" IS NULL
Affected rows: 1847 # All orders with null shopifyOrderId deleted!
Step 1: Understanding the Error
The webhook handler has three critical flaws that combine to create a data deletion disaster. First, it attempts to parse JSON that Next.js has already parsed, causing JSON.parse() to fail silently or throw errors. Second, it accesses the wrong field name (order_id instead of id), resulting in undefined values. Third, Prisma's deleteMany with an undefined value matches all null records in the database.
Here's what actually happens when Shopify sends a webhook:
// What Shopify actually sends (raw body)
{
"id": 5106476343567, // Correct field name is 'id', not 'order_id'
"email": "customer@example.com", // Not 'customer_email'
"total_price": "199.99", // String, not number
"line_items": [...],
"created_at": "2024-01-15T10:30:00-05:00"
}
// After Next.js parsing (req.body is already an object)
console.log(typeof req.body); // "object", not "string"
console.log(req.body.id); // 5106476343567
console.log(req.body.order_id); // undefined!
The webhook verification is also completely missing, allowing anyone to send fake webhooks that trigger database operations.
Step 2: Identifying the Cause
The root causes trace back to several incorrect assumptions about webhook data handling. Next.js automatically parses JSON bodies when the Content-Type header is application/json, which Shopify webhooks use. Double-parsing an object throws an error or returns unexpected results.
// Demonstration of the parsing problem
const alreadyParsed = { id: 123, email: "test@example.com" };
try {
const doubleParsed = JSON.parse(alreadyParsed);
// This throws: SyntaxError: Unexpected token o in JSON
} catch (error) {
console.log("Parse error:", error.message);
}
// Even worse with undefined values
const undefinedId = undefined;
const whereClause = { shopifyOrderId: undefinedId };
// Prisma interprets this as: WHERE shopifyOrderId IS NULL
// This matches ALL records where shopifyOrderId is null!
The missing HMAC validation means the webhook accepts any POST request, making the system vulnerable to malicious data deletion:
# Anyone could send this and delete your data
$ curl -X POST http://yoursite.com/api/webhooks/shopify \
-H "Content-Type: application/json" \
-d '{"fake": "data"}'
Step 3: Implementing the Solution
First, we need to properly configure Next.js to receive raw body data for HMAC validation:
// pages/api/webhooks/shopify.js - WORKING SOLUTION
import { PrismaClient } from '@prisma/client';
import crypto from 'crypto';
import getRawBody from 'raw-body';
const prisma = new PrismaClient();
// Disable Next.js body parsing to get raw body for HMAC
export const config = {
api: {
bodyParser: false,
},
};
// Verify webhook authenticity using HMAC
function verifyWebhookSignature(rawBody, signature) {
const webhookSecret = process.env.SHOPIFY_WEBHOOK_SECRET;
if (!webhookSecret || !signature) {
console.error('Missing webhook secret or signature');
return false;
}
const hash = crypto
.createHmac('sha256', webhookSecret)
.update(rawBody, 'utf8')
.digest('base64');
// Shopify sends the signature in header as base64
return hash === signature;
}
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
// Get raw body for HMAC validation
const rawBody = await getRawBody(req, {
encoding: 'utf8',
});
// Verify webhook authenticity
const signature = req.headers['x-shopify-hmac-sha256'];
if (!verifyWebhookSignature(rawBody, signature)) {
console.error('Invalid webhook signature');
return res.status(401).json({ error: 'Unauthorized' });
}
// Now parse the verified body
const webhookData = JSON.parse(rawBody);
// Use correct field names from Shopify
const orderId = webhookData.id; // Not 'order_id'
const customerEmail = webhookData.email; // Not 'customer_email'
const totalPrice = parseFloat(webhookData.total_price); // Convert string to number
// Validate required fields before database operations
if (!orderId) {
console.error('Missing order ID in webhook data');
return res.status(400).json({ error: 'Invalid webhook data' });
}
// Safe upsert operation instead of delete + create
const order = await prisma.order.upsert({
where: {
shopifyOrderId: orderId.toString(), // Ensure string type
},
update: {
customerEmail: customerEmail || '',
totalPrice: totalPrice || 0,
updatedAt: new Date(),
},
create: {
shopifyOrderId: orderId.toString(),
customerEmail: customerEmail || '',
totalPrice: totalPrice || 0,
},
});
console.log(`Order ${orderId} processed successfully`);
// Shopify expects 200 status for successful processing
res.status(200).json({ received: true });
} catch (error) {
console.error('Webhook processing error:', error);
// Log specific error details for debugging
if (error instanceof SyntaxError) {
console.error('JSON parsing failed:', error.message);
} else if (error.code === 'P2002') {
console.error('Duplicate order detected');
}
// Return 500 to trigger Shopify retry
res.status(500).json({ error: 'Webhook processing failed' });
} finally {
await prisma.$disconnect();
}
}
Update the Prisma schema to enforce data integrity:
// schema.prisma - Updated with proper constraints
model Order {
id String @id @default(cuid())
shopifyOrderId String @unique // Required and unique
customerEmail String @default("")
totalPrice Float @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([shopifyOrderId]) // Add index for faster lookups
}
Don't forget to install the required dependency and set environment variables:
$ npm install raw-body
$ echo "SHOPIFY_WEBHOOK_SECRET=your_webhook_secret_here" >> .env.local
Step 4: Working Code Example
Here's a complete webhook handler with comprehensive error handling and logging:
// pages/api/webhooks/shopify/orders.js - Production-ready version
import { PrismaClient } from '@prisma/client';
import crypto from 'crypto';
import getRawBody from 'raw-body';
const prisma = new PrismaClient({
log: ['error', 'warn'], // Enable Prisma logging
});
export const config = {
api: {
bodyParser: false,
},
};
// Webhook topic handlers
const topicHandlers = {
'orders/create': handleOrderCreate,
'orders/updated': handleOrderUpdate,
'orders/cancelled': handleOrderCancel,
};
async function handleOrderCreate(data) {
const order = await prisma.order.create({
data: {
shopifyOrderId: data.id.toString(),
customerEmail: data.email || '',
totalPrice: parseFloat(data.total_price) || 0,
status: 'active',
orderNumber: data.order_number,
lineItems: JSON.stringify(data.line_items || []),
},
});
return order;
}
async function handleOrderUpdate(data) {
const order = await prisma.order.update({
where: { shopifyOrderId: data.id.toString() },
data: {
customerEmail: data.email || '',
totalPrice: parseFloat(data.total_price) || 0,
updatedAt: new Date(),
},
});
return order;
}
async function handleOrderCancel(data) {
const order = await prisma.order.update({
where: { shopifyOrderId: data.id.toString() },
data: {
status: 'cancelled',
cancelledAt: new Date(data.cancelled_at),
cancelReason: data.cancel_reason || 'customer',
},
});
return order;
}
// Retry logic for transient failures
async function processWithRetry(handler, data, maxRetries = 3) {
let lastError;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await handler(data);
} catch (error) {
lastError = error;
console.error(`Attempt ${attempt} failed:`, error.message);
// Don't retry on permanent failures
if (error.code === 'P2002' || error.code === 'P2025') {
throw error;
}
// Exponential backoff
if (attempt < maxRetries) {
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
}
}
}
throw lastError;
}
export default async function handler(req, res) {
const startTime = Date.now();
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
// Get webhook topic from headers
const topic = req.headers['x-shopify-topic'];
const shopDomain = req.headers['x-shopify-shop-domain'];
const apiVersion = req.headers['x-shopify-api-version'];
console.log(`Webhook received: ${topic} from ${shopDomain} (API: ${apiVersion})`);
try {
// Parse and verify webhook
const rawBody = await getRawBody(req, {
encoding: 'utf8',
limit: '1mb', // Prevent large payload attacks
});
const signature = req.headers['x-shopify-hmac-sha256'];
const webhookSecret = process.env.SHOPIFY_WEBHOOK_SECRET;
if (!webhookSecret) {
throw new Error('SHOPIFY_WEBHOOK_SECRET not configured');
}
const hash = crypto
.createHmac('sha256', webhookSecret)
.update(rawBody, 'utf8')
.digest('base64');
if (hash !== signature) {
console.error('Invalid signature:', {
expected: hash.substring(0, 10) + '...',
received: signature?.substring(0, 10) + '...'
});
return res.status(401).json({ error: 'Unauthorized' });
}
const webhookData = JSON.parse(rawBody);
// Process based on topic
const handler = topicHandlers[topic];
if (!handler) {
console.log(`No handler for topic: ${topic}`);
return res.status(200).json({ received: true, ignored: true });
}
// Process with retry logic
const result = await processWithRetry(handler, webhookData);
const processingTime = Date.now() - startTime;
console.log(`Webhook processed in ${processingTime}ms:`, {
topic,
orderId: webhookData.id,
result: result.id,
});
res.status(200).json({ received: true, processed: true });
} catch (error) {
console.error('Webhook error:', {
topic,
error: error.message,
code: error.code,
stack: error.stack,
});
// Return appropriate status based on error type
if (error.message === 'SHOPIFY_WEBHOOK_SECRET not configured') {
return res.status(500).json({ error: 'Server configuration error' });
}
if (error.code === 'P2025') {
// Record not found - might be okay for updates
return res.status(200).json({ received: true, skipped: true });
}
// Generic error - trigger Shopify retry
res.status(500).json({ error: 'Processing failed' });
} finally {
await prisma.$disconnect();
}
}
Step 5: Additional Tips & Related Errors
Testing webhooks locally requires proper setup. Use ngrok to expose your local server:
$ npm install -g ngrok
$ ngrok http 3000
# Use the HTTPS URL for Shopify webhook configuration
Create a test script to simulate Shopify webhooks:
// scripts/test-webhook.js
const crypto = require('crypto');
const axios = require('axios');
const webhookUrl = 'http://localhost:3000/api/webhooks/shopify/orders';
const webhookSecret = process.env.SHOPIFY_WEBHOOK_SECRET;
const testData = {
id: 5106476343567,
email: "test@example.com",
total_price: "199.99",
order_number: 1001,
line_items: [
{ title: "Test Product", quantity: 1, price: "199.99" }
]
};
const rawBody = JSON.stringify(testData);
const hash = crypto
.createHmac('sha256', webhookSecret)
.update(rawBody, 'utf8')
.digest('base64');
axios.post(webhookUrl, rawBody, {
headers: {
'X-Shopify-Topic': 'orders/create',
'X-Shopify-Hmac-Sha256': hash,
'X-Shopify-Shop-Domain': 'test-shop.myshopify.com',
'X-Shopify-API-Version': '2024-01',
'Content-Type': 'application/json',
}
}).then(response => {
console.log('Success:', response.data);
}).catch(error => {
console.error('Error:', error.response?.data || error.message);
});
Common related errors you might encounter include webhook timeout issues when processing takes too long. Shopify expects a response within 5 seconds. For long-running tasks, queue them for background processing:
// Queue webhook for background processing
await prisma.webhookQueue.create({
data: {
topic,
payload: rawBody,
signature,
status: 'pending',
}
});
// Respond immediately
res.status(200).json({ received: true, queued: true });
Database connection exhaustion can occur with high webhook volume. Always disconnect Prisma after use and consider connection pooling for production environments. Monitor your webhook processing with proper logging and alerting to catch issues before they cause data loss.