Webhooks
Merge avisa a tu sistema cuando pasa algo con un pedido: un POST con JSON firmado a la URL que configures. Sirve para facturar con tu sistema de CFE, actualizar un ERP o avisar a tu equipo.
Configurar
El dueño de la tienda los configura en Configuración → Webhooks: una URL por evento. La URL tiene que ser https, pública y en el puerto estándar (nada de localhost ni IPs privadas). Para probar mientras desarrollás, usá un túnel o un receptor de prueba y el botón Enviar prueba, que manda un pedido de ejemplo con "test": true.
Con el primer webhook se genera el secreto de firma de la tienda. Se muestra una sola vez: guardalo en tu sistema. Generar uno nuevo lo rota y el anterior deja de valer en el acto.
Eventos
| Evento | Cuándo se manda |
|---|---|
order.created |
Se hizo un pedido (pagado al momento, con pago a coordinar o cubierto por un cupón). |
order.paid |
Se confirmó el pago (por el medio de pago o a mano). |
order.cancelled |
Se canceló (a mano o porque el pago no llegó). |
order.fulfilled |
Se entregó (a mano o porque el envío avisó la entrega). |
refund.created |
Se reembolsó el pago. |
Cada evento se manda una vez por pedido y por webhook configurado.
Payload
POST con content-type: application/json. El cuerpo es un sobre con el pedido en data:
{
"id": "0b8f6c1e-7a52-4d0e-9a3b-5c2f1e8d4a10",
"event": "order.paid",
"apiVersion": "2026-11",
"createdAt": "2026-11-11T15:00:00.000Z",
"storeId": "3d9a2b7c-1f4e-4a8b-b6d5-9e0c2a1f7b34",
"data": {
"id": "8c3e1d2f-5b6a-4c7d-8e9f-0a1b2c3d4e5f",
"number": 1001,
"status": "paid",
"test": false,
"currency": "UYU",
"createdAt": "2026-11-11T14:58:12.000Z",
"paidAt": "2026-11-11T15:00:00.000Z",
"fulfilledAt": null,
"cancelledAt": null,
"refundedAt": null,
"customer": {
"id": "c1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8",
"name": "Ana Pérez",
"email": "ana@example.com",
"phone": "099 123 456"
},
"billing": {
"documentType": "rut",
"documentNumber": "211234560018",
"businessName": "Ana Pérez SRL"
},
"shippingAddress": {
"department": "Montevideo",
"city": "Montevideo",
"street": "Av. 18 de Julio",
"number": "1234",
"apartment": "501",
"notes": "Timbre 5B"
},
"deliveryKind": "delivery",
"shippingLabel": "Envío a domicilio",
"payment": { "connectorId": "mercadopago", "optionId": "card" },
"lineItems": [
{
"id": "f0e1d2c3-b4a5-4968-8776-655443322110",
"productId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"variantId": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e",
"title": "Remera lisa",
"variantTitle": "M / Negro",
"sku": "REM-M-NEG",
"quantity": 2,
"unitPriceMinor": 99000,
"totalMinor": 198000
}
],
"subtotalMinor": 198000,
"shippingMinor": 25000,
"discountMinor": 0,
"totalMinor": 223000,
"note": "Timbre 5B",
"statusUrl": "https://tienda.example/orders/…"
}
}
Sobre
| Campo | Tipo | Qué es |
|---|---|---|
id |
texto | Id de la entrega. El mismo en cada reintento: usalo para descartar repetidos. |
event |
texto | Uno de los eventos de arriba. |
apiVersion |
texto | Versión del formato (2026-11). |
createdAt |
fecha | Cuándo se generó el evento (ISO 8601, UTC). |
storeId |
texto | Id de la tienda. Si recibís avisos de varias tiendas, distinguilas con este campo. |
data |
objeto | El pedido, como quedó en el momento del evento. |
Pedido (data)
| Campo | Tipo | Qué es |
|---|---|---|
id |
texto | Id del pedido. |
number |
número | Número visible (#1001). |
status |
texto | pending, paid, fulfilled, cancelled o refunded. |
test |
booleano | Pedido de prueba (no facturar). |
currency |
texto | UYU o USD. |
createdAt |
fecha | Creación del pedido. |
paidAt, fulfilledAt, cancelledAt, refundedAt |
fecha o null | Cuándo cambió a cada estado. |
customer |
objeto | id (o null si compró como invitado), name, email, phone (o null). |
billing |
objeto o null | documentType (ci o rut), documentNumber, businessName (o null). Null si el comprador no pidió factura con documento. |
shippingAddress |
objeto o null | department (departamento de Uruguay), city, street, number, apartment, notes. Null si retira en el local. |
deliveryKind |
texto | delivery (envío) o pickup (retiro). |
shippingLabel |
texto | Método de envío elegido, como lo vio el comprador. |
payment |
objeto | connectorId (medio de pago, por ejemplo mercadopago) y optionId (opción dentro del medio). |
lineItems |
array | Productos: id, productId, variantId, title, variantTitle, sku, quantity, unitPriceMinor, totalMinor. productId y variantId pueden ser null (por ejemplo, si el producto ya no existe). |
subtotalMinor |
número | Suma de las líneas. |
shippingMinor |
número | Envío. |
discountMinor |
número | Descuentos. |
totalMinor |
número | Total cobrado. |
note |
texto o null | Nota del comprador. |
statusUrl |
texto | Página pública del pedido para el comprador. |
Montos en unidades menores (centésimos): 223000 son $ 2.230,00. Nunca uses decimales para sumar montos.
Versiones
apiVersion cambia solo si se quita o se cambia un campo. Pueden aparecer campos nuevos sin cambio de versión: tu código tiene que ignorar los que no conoce.
Headers
| Header | Qué es |
|---|---|
X-Merge-Event |
El evento. |
X-Merge-Webhook-Id |
Id de la entrega (igual a id del cuerpo). |
X-Merge-Api-Version |
Versión del formato. |
X-Merge-Hmac-Sha256 |
Firma: HMAC-SHA256 del cuerpo crudo con el secreto de la tienda, en base64. |
Verificar la firma
Verificá cada aviso antes de usarlo. Calculá el HMAC sobre los bytes del cuerpo tal cual llegaron (antes de parsear el JSON: un parseo y re-serializado cambia los bytes) y compará en tiempo constante.
Node
import { createHmac, timingSafeEqual } from 'node:crypto';
/** true si `signature` (header X-Merge-Hmac-Sha256) firma `rawBody` con `secret`. */
export function verifyMergeWebhook(rawBody, signature, secret) {
const expected = createHmac('sha256', secret).update(rawBody).digest();
const received = Buffer.from(signature ?? '', 'base64');
return received.length === expected.length && timingSafeEqual(received, expected);
}
Con Express, leé el cuerpo crudo solo en esa ruta:
import express from 'express';
import { verifyMergeWebhook } from './verify.js';
const app = express();
const secret = config.mergeWebhookSecret; // el secreto que te mostró Merge
app.post('/webhooks/merge', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyMergeWebhook(req.body, req.get('X-Merge-Hmac-Sha256'), secret)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
res.sendStatus(200); // respondé rápido…
queue.add(event); // …y procesá en segundo plano
});
PHP
<?php
$raw = file_get_contents('php://input');
$expected = base64_encode(hash_hmac('sha256', $raw, $secret, true));
$headers = array_change_key_case(getallheaders());
$received = $headers['x-merge-hmac-sha256'] ?? '';
if (!hash_equals($expected, $received)) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
http_response_code(200);
Responder y reintentos
- Respondé con un 2xx en menos de 10 segundos. Cualquier otra cosa es un fallo, incluido un 3xx: las redirecciones no se siguen.
- Si falla, Merge reintenta con esperas crecientes: 1, 5, 15, 60, 180, 360 y 600 minutos (hasta 8 intentos en unas 20 horas). Después la entrega queda como fallida y el dueño puede reintentarla a mano desde el registro de entregas.
- Si un webhook falla sin ningún éxito durante 24 horas, se desactiva y el dueño de la tienda recibe un email. Se reactiva desde la misma pantalla.
Idempotencia y orden
- Un mismo aviso puede llegar más de una vez (por ejemplo, si respondiste tarde). Guardá el
X-Merge-Webhook-Idy descartá los repetidos. - El orden no está garantizado: un
order.paidpuede llegar después de unorder.fulfilledreintentado. Decidí condata.statusy las fechas del pedido, no con el orden de llegada. - El pedido es una foto del momento del evento. Si necesitás el estado más reciente, usá el último aviso que recibiste por fecha.