Saltar al contenido
merge.uydevelopers

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-Id y descartá los repetidos.
  • El orden no está garantizado: un order.paid puede llegar después de un order.fulfilled reintentado. Decidí con data.status y 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.