Handler Express listo para producción · verificación HMAC-SHA256 · procesamiento asíncrono
crypto.timingSafeEqual para evitar timing attacks. Debes leer el cuerpo como raw bytes (antes de JSON.parse).// Generated with: shopify-webhooks skill // https://github.com/hookdeck/webhook-skills const crypto = require('crypto'); /** * Verifica la firma HMAC-SHA256 de un webhook de Shopify. * @param {Buffer} rawBody — body sin parsear (Express raw) * @param {string} hmacHeader — valor de X-Shopify-Hmac-SHA256 * @param {string} secret — SHOPIFY_API_SECRET * @returns {boolean} */ function verifyShopifyHmac(rawBody, hmacHeader, secret) { if (!hmacHeader) return false; const expected = crypto .createHmac('sha256', secret) .update(rawBody) .digest('base64'); try { return crypto.timingSafeEqual( Buffer.from(hmacHeader), Buffer.from(expected) ); } catch { return false; // buffers de distinto tamaño } } module.exports = { verifyShopifyHmac };
const express = require('express'); const { verifyShopifyHmac } = require('./verify'); const queue = require('./queue'); const router = express.Router(); const SECRET = process.env.SHOPIFY_API_SECRET; // ⚠️ raw body necesario ANTES de json() parser router.use(express.raw({ type: 'application/json' })); router.post('/shopify', (req, res) => { const hmac = req.headers['x-shopify-hmac-sha256']; const topic = req.headers['x-shopify-topic']; const shop = req.headers['x-shopify-shop-domain']; const webhookId = req.headers['x-shopify-webhook-id']; // 1. Verificar firma if (!verifyShopifyHmac(req.body, hmac, SECRET)) { console.warn({ event: 'webhook.rejected', shop, topic }); return res.status(401).json({ error: 'Invalid signature' }); } // 2. Parsear payload const payload = JSON.parse(req.body); // 3. Responder 200 inmediatamente (Shopify espera < 5 s) res.status(200).send('OK'); // 4. Encolar procesamiento asíncrono queue.enqueue({ topic, shop, webhookId, payload }); console.log(JSON.stringify({ event: 'webhook.received', topic, shop, webhookId, orderId: payload.id ?? null, ts: new Date().toISOString() })); }); module.exports = router;
const seen = new Set(); // idempotencia en memoria (prod → Redis) const handlers = { 'orders/create': require('./handlers/orders-create'), 'orders/fulfilled': require('./handlers/orders-fulfilled'), 'customers/create': require('./handlers/customers-create'), 'products/update': require('./handlers/products-update'), }; async function process({ topic, shop, webhookId, payload }) { // Deduplicación por webhookId if (webhookId && seen.has(webhookId)) { console.log({ event: 'webhook.duplicate', webhookId }); return; } if (webhookId) seen.add(webhookId); const handler = handlers[topic]; if (!handler) { console.warn({ event: 'webhook.unhandled', topic }); return; } try { await handler({ shop, payload }); console.log({ event: 'webhook.handled', topic, webhookId }); } catch (err) { console.error({ event: 'webhook.error', topic, webhookId, msg: err.message }); // Shopify reintentará si devolvemos 200; en prod añadir DLQ } } function enqueue(job) { // setImmediate garantiza respuesta 200 ANTES de procesar setImmediate(() => process(job)); } module.exports = { enqueue };
const axios = require('axios'); /** * Sincroniza un pedido nuevo con Holded ERP. * Topic: orders/create */ module.exports = async function handleOrderCreate({ payload }) { const order = { externalId: String(payload.id), contactName: `${payload.customer?.first_name} ${payload.customer?.last_name}`, contactEmail: payload.email, items: payload.line_items.map(li => ({ sku: li.sku, name: li.name, quantity: li.quantity, price: parseFloat(li.price), })), total: parseFloat(payload.total_price), currency: payload.currency, createdAt: payload.created_at, }; await axios.post( 'https://api.holded.com/api/invoicing/v1/documents/salesorder', order, { headers: { key: process.env.HOLDED_API_KEY } } ); };
# Instalar CLI (una vez) npm install -g hookdeck-cli # Iniciar túnel — expone localhost:3000/webhooks/shopify a Shopify hookdeck-cli listen 3000 shopify --path /webhooks/shopify # Salida esperada: Listening for Shopify webhooks... https://events.hookdeck.com/e/src_xxxx → localhost:3000/webhooks/shopify # Simular evento con payload de prueba curl -X POST http://localhost:3000/webhooks/shopify \ -H "Content-Type: application/json" \ -H "X-Shopify-Topic: orders/create" \ -H "X-Shopify-Shop-Domain: petnourish.myshopify.com" \ -H "X-Shopify-Hmac-SHA256: $(node -e " const c=require('crypto'); const body=JSON.stringify({id:12345,email:'owner@petnourish.es'}); console.log(c.createHmac('sha256',process.env.SHOPIFY_API_SECRET) .update(body).digest('base64')) ")" \ -d '{"id":12345,"email":"owner@petnourish.es","total_price":"49.90"}'
https://api.petnourish.es/webhooks/shopify. Shopify reintentará hasta 19 veces en 48 h si recibe algo distinto de 2xx.