Shopify Webhooks

PetNourish — Webhook Handler

Handler Express listo para producción · verificación HMAC-SHA256 · procesamiento asíncrono

Node.js + Express Railway · api.petnourish.es 4 topics activos CULTIVA IA · Web Dev
Flujo de procesamiento
📦
Shopify
envía POST
🔐
Verificar
HMAC-SHA256
200 OK
inmediato
Cola asíncrona
por topic
🎯
ERP / CRM /
WhatsApp / WMS
Topics suscritos — petnourish.myshopify.com
orders/create
Nuevo pedido realizado
→ Sync ERP Holded
orders/fulfilled
Pedido enviado al cliente
→ WhatsApp + WMS
customers/create
Nuevo cliente registrado
→ CRM upsert
products/update
Producto modificado
→ Invalidar caché

1 · Utilidad de verificación
⚠️
Shopify envía el digest en base64, no en hex. Usa crypto.timingSafeEqual para evitar timing attacks. Debes leer el cuerpo como raw bytes (antes de JSON.parse).
JS src/webhooks/verify.js
// 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 };
2 · Router principal
JS src/webhooks/shopify.router.js
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;
3 · Cola asíncrona + handlers por topic
JS src/webhooks/queue.js
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 };
4 · Handler orders/create → Holded ERP
JS src/webhooks/handlers/orders-create.js
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 } }
  );
};
Handler orders/fulfilled sigue el mismo patrón: llama a la API de Twilio para WhatsApp y al WMS con el fulfillment_id del payload. customers/create hace upsert en el CRM. products/update invalida la caché Redis con el product.id.
5 · Desarrollo local con Hookdeck CLI
BASH terminal
# 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"}'
En producción, registra los webhooks desde el Shopify Partner Dashboard → App → Webhooks apuntando a https://api.petnourish.es/webhooks/shopify. Shopify reintentará hasta 19 veces en 48 h si recibe algo distinto de 2xx.