Webhooks

Integrá ForkCast con tu sistema de facturación, ERP o cualquier servicio externo.

En esta página

Descripción general

ForkCast dispara webhooks HTTP POST cuando ocurren eventos en la plataforma. Los webhooks permiten integrar sistemas externos — facturación, ERP, notificaciones, analytics — sin modificar ForkCast.

Configuración

Los webhooks se configuran por usuario (restaurante o proveedor) desde Configuración o via API:

POST /api/webhooks
{
  "url": "https://tu-sistema.com/webhook",
  "events": ["order.created", "order.status_updated"]
}

Seguridad: El secret se genera automáticamente (64 caracteres hex) y se retorna en el response de creación. Guardalo de forma segura — no se puede recuperar después. La URL debe usar HTTPS (excepto localhost en desarrollo).

Verificación de firma

Cada request incluye dos headers de seguridad:

X-Webhook-SignatureHMAC-SHA256 del payload firmado con el secret
X-Webhook-TimestampUnix timestamp (segundos) del momento del dispatch
X-Webhook-Signature: sha256=a1b2c3d4e5f6...
X-Webhook-Timestamp: 1743456789

La firma se calcula sobre "${timestamp}.${body}" (timestamp + punto + body JSON) para prevenir replay attacks.

Para verificar en Node.js:

const crypto = require('crypto');

function verifyWebhook(req, secret) {
  const timestamp = req.headers['x-webhook-timestamp'];
  const receivedSig = req.headers['x-webhook-signature'];

  // Rechazar si el timestamp tiene más de 5 minutos
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp, 10)) > 300) {
    throw new Error('Timestamp demasiado antiguo (posible replay attack)');
  }

  // Verificar firma con timingSafeEqual
  const body = JSON.stringify(req.body);
  const signaturePayload = `${timestamp}.${body}`;
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(signaturePayload)
    .digest('hex');

  const a = Buffer.from(receivedSig);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    throw new Error('Firma inválida');
  }
}

Importante: Usá siempre crypto.timingSafeEqual() para comparar firmas, no ===. La comparación directa es vulnerable a timing attacks.

Eventos disponibles

Evento Descripción
order.created Pedido creado
order.status_updated Estado actualizado

Payload

Ambos eventos envían el mismo formato:

{
  "event": "order.status_updated",
  "data": {
    "orderId": 123,
    "restaurantId": 1,
    "supplierId": 5,
    "priceListId": 4,
    "status": "delivered",
    "date": "2026-04-01T00:00:00.000Z",
    "items": [
      {
        "name": "GIN BOSQUE NATIVO 500ML",
        "quantity": 10,
        "unit": "un",
        "price": 4407.81
      }
    ],
    "isBillable": true,
    "client": {
      "forkcastId": 1,
      "name": "CAMINANTES S.R.L",
      "externalMappings": [
        {
          "provider": "contabilium",
          "externalId": "91003015",
          "externalName": "CAMINANTES S.R.L"
        }
      ]
    }
  }
}

Campos del payload

data

CampoTipoDescripción
orderIdnumberID del pedido en ForkCast
restaurantIdnumberID del restaurante
supplierIdnumber | nullID del proveedor
priceListIdnumber | nullID de la lista de precios
statusstringpending, approved, rejected, delivered
dateISO 8601Fecha del pedido
itemsarrayProductos del pedido
isBillablebooleanSi la lista es facturable
clientobjectDatos del cliente

items[]

CampoTipoDescripción
namestringNombre del producto
quantitynumberCantidad pedida
unitstringUnidad (kg, un, l, etc.)
pricenumberPrecio unitario

client

CampoTipoDescripción
forkcastIdnumberID del restaurante en ForkCast
namestringNombre del restaurante
externalMappingsarrayMappings con sistemas externos

externalMappings[]

CampoTipoDescripción
providerstringSistema externo (contabilium, xubio, etc.)
externalIdstringID del cliente en el sistema externo
externalNamestring | nullRazón social en el sistema externo

Estados del pedido

pending approved delivered
pending rejected

Caso de uso: Facturación automática

Cuando un pedido se marca como entregado y la lista de precios es facturable:

  1. 1Recibir webhook order.status_updated con status: "delivered"
  2. 2Verificar isBillable: true
  3. 3Usar client.externalMappings para encontrar el ID del cliente en tu ERP
  4. 4Crear el comprobante en el ERP con los items del pedido

Ejemplos de código

Node.js (Express)

const crypto = require('crypto');
const WEBHOOK_SECRET = process.env.FORKCAST_WEBHOOK_SECRET;

app.post('/webhook/forkcast', (req, res) => {
  // 1. Verificar firma y timestamp
  const timestamp = req.headers['x-webhook-timestamp'];
  const sig = req.headers['x-webhook-signature'];
  const now = Math.floor(Date.now() / 1000);

  if (Math.abs(now - parseInt(timestamp)) > 300) {
    return res.status(403).json({ error: 'Timestamp expired' });
  }

  const expected = 'sha256=' + crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(`${timestamp}.${JSON.stringify(req.body)}`)
    .digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return res.status(403).json({ error: 'Invalid signature' });
  }

  // 2. Procesar evento
  const { event, data } = req.body;

  if (event === 'order.status_updated'
    && data.status === 'delivered'
    && data.isBillable) {

    const mapping = data.client.externalMappings
      .find(m => m.provider === 'contabilium');

    if (mapping) {
      crearComprobante({
        clienteId: mapping.externalId,
        items: data.items.map(i => ({
          concepto: i.name,
          cantidad: i.quantity,
          precioUnitario: i.price,
        })),
      });
    }
  }

  res.status(200).json({ ok: true });
});

Python (FastAPI)

import hmac, hashlib, time, os

WEBHOOK_SECRET = os.environ["FORKCAST_WEBHOOK_SECRET"]

@app.post("/webhook/forkcast")
async def webhook(request: Request):
    raw_body = await request.body()
    timestamp = request.headers.get("x-webhook-timestamp", "")
    received_sig = request.headers.get("x-webhook-signature", "")

    # Verificar timestamp (max 5 min)
    if abs(time.time() - int(timestamp)) > 300:
        return {"error": "Timestamp expired"}, 403

    # Verificar firma
    signature_payload = f"{timestamp}.{raw_body.decode()}"
    expected = "sha256=" + hmac.new(
        WEBHOOK_SECRET.encode(), signature_payload.encode(), hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(received_sig, expected):
        return {"error": "Invalid signature"}, 403

    # Procesar evento
    body = await request.json()
    event = body.get("event")
    data = body.get("data", {})

    if (event == "order.status_updated"
        and data.get("status") == "delivered"
        and data.get("isBillable")):

        mappings = data["client"]["externalMappings"]
        contabilium = next(
            (m for m in mappings if m["provider"] == "contabilium"),
            None
        )

        if contabilium:
            crear_comprobante(
                cliente_id=contabilium["externalId"],
                items=[{
                    "concepto": i["name"],
                    "cantidad": i["quantity"],
                    "precio_unitario": i["price"],
                } for i in data["items"]]
            )

    return {"ok": True}

Retry policy

Los webhooks se envían una vez (fire-and-forget). Si tu servidor no responde o devuelve error, el webhook no se reintenta.

Recomendamos implementar un mecanismo de reconciliación consultando GET /api/purchase-orders periódicamente para detectar pedidos que no fueron procesados.