Webhooks

Webhooks

Te avisamos en el momento cuando algo cambia de nuestro lado: un pedido, el stock, un producto. Dejás de hacer polling.

Cómo funciona

Registrás una URL HTTPS y te suscribís a los eventos que te interesan. Cuando algo pasa en Mandadito, hacemos un POST a tu URL con el evento, firmado para que puedas verificar que lo mandamos nosotros. Si tu endpoint responde un 2xx, la entrega queda confirmada; si no, reintentamos (más abajo).

La clave: un webhook te avisa de lo que pasa de nuestro lado (una venta que descontó stock, un pedido nuevo, un cambio que hizo el comerciante en el panel). Un cambio que vos hiciste por la API no te vuelve como webhook — ya lo sabés.

Crear un endpoint

Dos caminos, los dos equivalentes:

  • Desde el panel del comercio, en Ajustes → API e integraciones.
  • Desde la API, con una clave que tenga el permiso webhooks:manage:
curl -X POST https://api.mandaditoapp.com/partner/v1/webhooks/endpoints \
  -H "Authorization: Bearer mdt_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://erp.example.com/mandadito/webhooks",
    "description": "ERP producción",
    "events": ["order.created", "order.paid", "order.cancelled", "inventory.updated"]
  }'

La respuesta trae el secreto de firma una sola vez (whsec_…). Guardalo ahí: no se puede volver a ver. Si lo perdés, rotalo (POST /webhooks/endpoints/:id/rotate-secret): el anterior sigue firmando 24 horas para que actualices tu sistema sin cortes.

{
  "endpoint": { "_id": "66c1...", "url": "https://erp.example.com/mandadito/webhooks", "enabled": true, "events": ["order.created", "..."] },
  "secret": "whsec_aBcD..."
}

Los eventos

EventoCuándo
order.createdEntró un pedido.
order.paidEl pedido quedó pagado (una compra con tarjeta lo dispara junto con order.created; un pedido en efectivo, cuando el comercio lo marca pagado).
order.updatedCambió el estado, el pago, el envío o la asignación del pedido.
order.cancelledSe canceló el pedido.
refund.createdSe reembolsó (total o parcial) un pedido pago.
inventory.updatedSe movió el stock por una causa nuestra: una venta, una cancelación, un ajuste del comercio, una importación. No por tus escrituras de la API.
stock_transfer.createdEl comercio envió stock de una sucursal a otra. Las unidades salen del origen (eso también llega como un inventory.updated por producto, con transferId) y viajan hasta que se reciben.
stock_transfer.receivedLa transferencia llegó al destino. receivedQuantity por línea puede ser menor que quantity si se vendieron unidades mientras viajaban.
stock_transfer.cancelledSe canceló la transferencia y el stock volvió al origen (returnedQuantity por línea).
customer.createdNecesita también el permiso customers:read (lleva datos de contacto y fiscales). Se creó un cliente de nuestro lado: el comercio lo cargó en el panel, entró con una venta de mostrador o hizo su primera compra en la app. data es el cliente como en GET /customers/:id.
customer.updatedEl comercio editó el cliente o sus etiquetas en el panel. No por una venta (eso es order.*) ni por cambios de segmentos.
customer.deletedEl comercio borró el cliente. data es { id, externalId }: el registro ya no existe.
product.created · product.updated · product.deletedCambió un producto del catálogo por una causa nuestra (el panel del comercio, una importación). No por tus escrituras de la API (tampoco los customer.*); para esas, releé con GET /products?updatedSince= o GET /customers?updatedSince=.
pingSolo el que mandás con "Probar" / POST /webhooks/endpoints/:id/test.

El payload

El cuerpo es siempre el mismo sobre; data es el recurso con el mismo formato que la API REST (por ejemplo, data de un order.* es el pedido como lo ves en GET /orders/:id).

{
  "id": "66c1a2...",            // id del evento, estable entre reintentos
  "type": "inventory.updated",
  "apiVersion": "v1",
  "createdAt": "2026-10-20T19:47:17.096Z",
  "storeId": "66b0...",
  "data": {
    "variantId": "66b0...", "productId": "66b0...", "locationId": "66b0...",
    "quantity": 698,          // stock EN MANO después del movimiento
    "delta": -2, "source": "sale", "reason": null, "orderName": "#42",
    "transferId": null        // en una transferencia, el id del stock_transfer.* (agrupalos o ignoralos)
  }
}

El data de un stock_transfer.* es la transferencia completa:

{
  "id": "66c1...", "status": "received",
  "fromLocation": { "id": "66b0...", "title": "Principal" },
  "toLocation": { "id": "66b0...", "title": "Depósito Norte" },
  "lines": [{ "variantId": "66b0...", "productId": "66b0...", "title": "Yerba 1 kg", "sku": "Y-1",
              "quantity": 12, "receivedQuantity": 12, "returnedQuantity": 0 }],
  "units": 12, "note": null, "createdBy": "Ana",
  "shippedAt": "2026-10-23T10:00:00.000Z", "receivedAt": "2026-10-23T12:00:00.000Z", "cancelledAt": null,
  "remitos": [{ "kind": "X", "number": "0001-00000007", "status": "issued" }],
  "createdAt": "2026-10-23T10:00:00.000Z"
}

Verificar la firma (Standard Webhooks)

Firmamos con el estándar Standard Webhooks, así lo verificás con las librerías oficiales. Mandamos tres headers:

  • webhook-id — el id del evento (estable entre reintentos; úsalo para deduplicar).
  • webhook-timestamp — segundos Unix.
  • webhook-signature — una o más entradas v1,<base64> separadas por espacio (dos mientras rota el secreto).

Lo firmado es {id}.{timestamp}.{cuerpo-crudo} con HMAC-SHA256 y el secreto. La forma recomendada es la librería:

// Node — npm i standardwebhooks
import { Webhook } from "standardwebhooks";

const wh = new Webhook(process.env.MANDADITO_WEBHOOK_SECRET); // whsec_...
app.post("/mandadito/webhooks", express.raw({ type: "*/*" }), (req, res) => {
  let event;
  try {
    event = wh.verify(req.body, {           // req.body es el BUFFER crudo, sin parsear
      "webhook-id": req.header("webhook-id"),
      "webhook-timestamp": req.header("webhook-timestamp"),
      "webhook-signature": req.header("webhook-signature"),
    });
  } catch {
    return res.sendStatus(400);             // firma inválida
  }
  // ... procesá event.type / event.data ...
  res.sendStatus(200);
});
# Python — pip install standardwebhooks
from standardwebhooks import Webhook

wh = Webhook(os.environ["MANDADITO_WEBHOOK_SECRET"])
event = wh.verify(raw_body, dict(request.headers))   # lanza si no valida
// PHP — composer require standard-webhooks/standard-webhooks
use StandardWebhooks\Webhook;
$wh = new Webhook($_ENV['MANDADITO_WEBHOOK_SECRET']);
$event = $wh->verify($rawBody, $headers);            // lanza si no valida

Si preferís verificar a mano: calculá base64(HMAC_SHA256(secreto, "{id}.{timestamp}.{cuerpo}")), armá v1,<eso> y compará (tiempo constante) contra cada entrada del header. Rechazá un timestamp a más de 5 minutos de ahora.

Verificá sobre el cuerpo crudo, no sobre el JSON reparseado: cualquier reserialización cambia los bytes y la firma deja de dar.

Reintentos y auto-desactivación

Si tu endpoint no responde 2xx (o tarda más de 10 s), reintentamos con esta escala: 1 min, 5 min, 30 min, 2 h, 6 h, 24 h, y después marcamos la entrega como agotada. Tras 25 fallas seguidas desactivamos el endpoint y le avisamos por mail al dueño del comercio; al reactivarlo (desde el panel o PATCH .../:id) se reinicia el contador. Podés revisar cada intento con GET /webhooks/endpoints/:id/deliveries.

Buenas prácticas

  • Deduplicá por webhook-id. Un reintento trae el mismo id; procesá una sola vez.
  • Respondé rápido. Devolvé 2xx apenas lo recibís y hacé el trabajo pesado después; si tardás, reintentamos.
  • El orden no está garantizado. Compará updatedAt o reconsultá el recurso ante un conflicto, en vez de asumir que llegan en secuencia.
  • No reacciones a tus propios cambios. Tus escrituras por la API no generan webhooks; si igual ves un inventory.updated, mirá source para entender de dónde salió.
  • Rotá el secreto cada tanto; verificá contra las dos entradas de la firma durante la ventana de rotación.

¿Permisos y códigos de error? Autenticación. La referencia completa, con cada ruta y campo, está en api.mandaditoapp.com/docs.