WooCommerce HMAC-SHA256 Express Next.js FastAPI

WooCommerce Webhooks: Recepción y Verificación

Implementación completa para TiendaCultiva — tienda de productos digitales IA

Eventos configurados
order.created
Nuevo pedido realizado en la tienda
Activar acceso Email bienvenida CRM
order.updated
Cambio de estado del pedido
Factura Licencia Reembolso
customer.created
Nuevo cliente registrado
Sync CRM Email mktg
product.updated
Producto modificado
Actualizar catálogo Limpiar caché
order.deleted
Pedido eliminado
Revocar acceso Cleanup
Flujo de verificación
📦
WooCommerce
POST + firma
🔐
HMAC-SHA256
raw body + secret
Verificado
Procesar evento
|
🚫
Inválido
HTTP 400
Variables de entorno
# .env — TiendaCultiva
WOOCOMMERCE_WEBHOOK_SECRET=tc_wh_a9f3c72b4d1e8a05f6b2c...
WC_STORE_URL=https://tiendacultiva.es
CRM_WEBHOOK_URL=https://crm.tiendacultiva.es/api/contacts
Express — Receptor principal (Node.js)
JavaScript  ·  Express
src/webhooks/woocommerce.js
const crypto = require('crypto');
const express = require('express');
const router = express.Router();

// ⚠️  CRÍTICO: body raw para que la firma coincida
router.use('/webhooks/woocommerce', express.raw({ type: 'application/json' }));

function verifyWooCommerceWebhook(rawBody, signature, secret) {
  if (!signature || !secret) return false;
  const hash = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('base64');
  try {
    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(hash)
    );
  } catch { return false; }
}

// Handler por evento
const handlers = {
  'order.created': async (payload) => {
    await activateDigitalAccess(payload.id, payload.line_items);
    await sendWelcomeEmail(payload.billing.email, payload.billing.first_name);
    await syncCRM({ email: payload.billing.email, orderId: payload.id });
  },
  'order.updated': async (payload) => {
    if (payload.status === 'completed') {
      await issueInvoice(payload.id);
      await activateLicense(payload.id);
    }
    if (payload.status === 'refunded') {
      await revokeAccess(payload.id);
    }
  },
  'customer.created': async (payload) => {
    await syncCRM({ email: payload.email, name: payload.first_name });
    await addToEmailList(payload.email);
  },
  'product.updated': async (payload) => {
    await updateCatalogCache(payload.id, payload);
  },
};

router.post('/webhooks/woocommerce', async (req, res) => {
  const signature = req.headers['x-wc-webhook-signature'];
  const secret = process.env.WOOCOMMERCE_WEBHOOK_SECRET;
  const topic = req.headers['x-wc-webhook-topic'];

  if (!verifyWooCommerceWebhook(req.body, signature, secret)) {
    return res.status(400).json({ error: 'Firma inválida' });
  }

  const payload = JSON.parse(req.body);
  const handler = handlers[topic];

  if (handler) await handler(payload);

  res.status(200).json({ received: true, topic });
});

module.exports = router;
Next.js App Router — Dashboard de clientes
TypeScript  ·  Next.js App Router
app/api/webhooks/woocommerce/route.ts
import crypto from 'crypto';
import { NextRequest } from 'next/server';

// Forzar evaluación dinámica — webhooks no son cacheables
export const dynamic = 'force-dynamic';

function verifyWooCommerceWebhook(
  rawBody: string,
  signature: string | null,
  secret: string | undefined
): boolean {
  if (!signature || !secret) return false;
  const hash = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('base64');
  try {
    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(hash)
    );
  } catch { return false; }
}

export async function POST(request: NextRequest) {
  const signature = request.headers.get('x-wc-webhook-signature');
  const topic = request.headers.get('x-wc-webhook-topic');
  const secret = process.env.WOOCOMMERCE_WEBHOOK_SECRET;

  // Leer body como texto para mantener raw original
  const rawBody = await request.text();

  if (!verifyWooCommerceWebhook(rawBody, signature, secret)) {
    return new Response('Firma inválida', { status: 400 });
  }

  const payload = JSON.parse(rawBody);

  // Revalidar caché del dashboard al recibir eventos relevantes
  if (topic?.startsWith('order.')) {
    await revalidateDashboard(payload.id);
  }

  return Response.json({ ok: true, topic }, { status: 200 });
}
FastAPI — Microservicio de facturación (Python)
Python  ·  FastAPI
billing/webhooks.py
import hmac, hashlib, base64, os
from fastapi import FastAPI, Request, HTTPException
from pydantic import BaseModel

app = FastAPI(title="TiendaCultiva · Billing Webhook")

def verify_woocommerce_webhook(
    raw_body: bytes, signature: str, secret: str
) -> bool:
    if not signature or not secret:
        return False
    hash_digest = hmac.new(
        secret.encode(),
        raw_body,
        hashlib.sha256
    ).digest()
    expected = base64.b64encode(hash_digest).decode()
    return hmac.compare_digest(signature, expected)

@app.post("/webhooks/woocommerce")
async def handle_webhook(request: Request):
    raw_body = await request.body()
    signature = request.headers.get("x-wc-webhook-signature", "")
    topic    = request.headers.get("x-wc-webhook-topic", "")
    secret   = os.getenv("WOOCOMMERCE_WEBHOOK_SECRET")

    if not verify_woocommerce_webhook(raw_body, signature, secret):
        raise HTTPException(status_code=400, detail="Firma inválida")

    payload = await request.json()

    if topic == "order.created":
        await create_invoice_draft(payload["id"], payload["billing"])

    elif topic == "order.updated" and payload.get("status") == "completed":
        await finalize_and_send_invoice(payload["id"])

    return {"ok": True, "topic": topic}
Cabeceras WooCommerce
Cabecera Ejemplo Descripción
X-WC-Webhook-Signature dGVzdHNlY3JldA==... Firma HMAC-SHA256 en base64 — verificar siempre
X-WC-Webhook-Topic order.created Tipo de evento: <recurso>.<acción>
X-WC-Webhook-Resource order Tipo de recurso: order, product, customer
X-WC-Webhook-Event created Acción: created, updated, deleted
X-WC-Webhook-Source https://tiendacultiva.es URL de origen de la tienda
X-WC-Webhook-ID 42 ID del webhook en WooCommerce admin
X-WC-Webhook-Delivery-ID wcd_f2a9b3... ID único de entrega — útil para idempotencia
Errores comunes y buenas prácticas
No uses express.json() antes de verificar
Si parseas el body antes de verificar, la firma fallará siempre. El cuerpo raw debe llegar intacto a la función HMAC. Usa express.raw() en la ruta del webhook.
Buffer.from() de longitudes distintas lanza error
Usa siempre un bloque try/catch alrededor de timingSafeEqual. Si la firma recibida tiene un tamaño diferente, la comparación lanza excepción.
timingSafeEqual previene timing attacks
Nunca compares las firmas con ===. Usa crypto.timingSafeEqual (JS) o hmac.compare_digest (Python) para que el tiempo de respuesta no revele información.
Idempotencia con X-WC-Webhook-Delivery-ID
WooCommerce reintenta hasta 5 veces. Guarda el X-WC-Webhook-Delivery-ID en Redis/DB para detectar entregas duplicadas y devolver 200 sin reprocesar.
Desarrollo local con Hookdeck CLI
Shell
tunnel local → tiendacultiva.es
# Instalar Hookdeck CLI (sin cuenta requerida)
npx hookdeck-cli listen 3000 woocommerce --path /webhooks/woocommerce

# Output esperado:
# Dashboard   → https://dashboard.hookdeck.com?token=...
# Receive      → https://hkdk.events/xxxx → localhost:3000/webhooks/woocommerce
# Configurar esta URL en WooCommerce Admin → Ajustes → Avanzado → Webhooks