Implementación completa para TiendaCultiva — tienda de productos digitales IA
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;
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 }); }
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}
| 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 |
express.raw() en la ruta del webhook.try/catch alrededor de timingSafeEqual. Si la firma recibida tiene un tamaño diferente, la comparación lanza excepción.===. Usa crypto.timingSafeEqual (JS) o hmac.compare_digest (Python) para que el tiempo de respuesta no revele información.X-WC-Webhook-Delivery-ID en Redis/DB para detectar entregas duplicadas y devolver 200 sin reprocesar.# 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