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
| Evento | Cuándo |
|---|---|
order.created | Entró un pedido. |
order.paid | El pedido quedó pagado (una compra con tarjeta lo dispara junto con order.created; un pedido en efectivo, cuando el comercio lo marca pagado). |
order.updated | Cambió el estado, el pago, el envío o la asignación del pedido. |
order.cancelled | Se canceló el pedido. |
refund.created | Se reembolsó (total o parcial) un pedido pago. |
inventory.updated | Se 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.created | El 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.received | La transferencia llegó al destino. receivedQuantity por línea puede ser menor que quantity si se vendieron unidades mientras viajaban. |
stock_transfer.cancelled | Se canceló la transferencia y el stock volvió al origen (returnedQuantity por línea). |
customer.created | Necesita 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.updated | El comercio editó el cliente o sus etiquetas en el panel. No por una venta (eso es order.*) ni por cambios de segmentos. |
customer.deleted | El comercio borró el cliente. data es { id, externalId }: el registro ya no existe. |
product.created · product.updated · product.deleted | Cambió 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=. |
ping | Solo 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 entradasv1,<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 validaSi 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é
2xxapenas lo recibís y hacé el trabajo pesado después; si tardás, reintentamos. - El orden no está garantizado. Compará
updatedAto 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ásourcepara 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.