Ir al contenido

Webhooks

Plus y Pro

Cuando pasa algo, Leadit Reviews manda un POST con JSON a la URL que configuraste en Canales → Webhook saliente. Es la base de un canal propio: tú recibes review_request.due y mandas el mensaje.

Evento En la app Cuándo llega
review_request.due Solicitud lista para mandar Llegó el día de pedir la reseña. Escucharlo te vuelve el canal: Leadit Reviews ya no le escribe al cliente.
review_request.reminder Toca mandar un recordatorio El cliente no ha contestado. Trae reminder con el número de recordatorio (1, 2…).
review_request.responded El cliente terminó el formulario Reseñó o saltó cada producto de su pedido. Los recordatorios se apagan.
review.created Reseña nueva Entró una reseña por el formulario, el QR o la API. Las importadas no avisan.
review.published Reseña publicada Una reseña pasó a publicada.
review.rejected Reseña rechazada Una reseña fue rechazada. Trae rejection_reason.
POST /tu-endpoint HTTP/1.1
Content-Type: application/json
User-Agent: LeaditReviews-Webhook/1.0
X-Event-Id: review_request.due:cm2k8x1f30001:1760630400000
X-Event-Type: review_request.due
X-Delivery-Attempt: 1
X-Timestamp: 1760630402
X-Signature: sha256=5d1b0c…

Todos los eventos llegan en el mismo sobre:

Campo Qué es
id Único por hecho. Igual en X-Event-Id. Úsalo para no procesar dos veces el mismo aviso.
type El evento.
created_at Cuándo lo armamos.
shop.domain La tienda (cumbre.myshopify.com).
data El contenido: una solicitud o una reseña (abajo).
test Solo en las pruebas de Enviar prueba: true. No actúes sobre ellas.

review_request.due, review_request.reminder y review_request.responded traen la solicitud en data, con la misma forma que la respuesta de POST /review-requests.

review_request.due
{
"id": "review_request.due:cm2k8x1f30001:1760630400000",
"type": "review_request.due",
"created_at": "2026-10-16T16:00:02.114Z",
"shop": { "domain": "cumbre.myshopify.com" },
"data": {
"request_id": "cm2k8x1f30001",
"url": "https://reviews.leadit.tech/r/8Kj2mQx7RrT0vLw3Hn5ZpA1cYb9EuS6dGf4qWk2NtJo",
"token": "8Kj2mQx7RrT0vLw3Hn5ZpA1cYb9EuS6dGf4qWk2NtJo",
"status": "due",
"channel": "link",
"locale": null,
"scheduled_at": "2026-10-16T16:00:00.000Z",
"expires_at": "2026-12-15T16:00:00.000Z",
"reminders_sent": 0,
"customer": {
"name": "María",
"email": "maria@correo.com",
"phone": "+525512345678",
"phone_confidence": "declared",
"phone_raw": "55 1234 5678",
"country": "MX"
},
"order": { "id": "gid://shopify/Order/5812345678901", "name": "#1042" },
"products": [
{
"id": "gid://shopify/Product/8123456789012",
"title": "Sudadera Arena",
"variant_title": "Arena / M",
"image_url": "https://cdn.shopify.com/s/files/1/0000/0001/files/sudadera-arena.jpg",
"url": "https://cumbre.mx/products/sudadera-arena"
}
]
}
}
Campo de data Qué es
request_id El id de la solicitud. Úsalo en la API (/review-requests/{id}/…).
url El enlace completo del formulario.
token Lo que va al final de url, después de /r/. Es la variable del botón de URL dinámica de una plantilla de WhatsApp: https://reviews.leadit.tech/r/{{1}}.
status scheduled, due, paused, dispatched, opened, partial, responded, expired, cancelled o failed.
locale es o en. null: el idioma que la tienda eligió para su formulario.
expires_at Hasta cuándo sirve el enlace.
reminders_sent Cuántos recordatorios se han mandado.
customer.name El nombre del cliente. Puede ser null.
customer.phone En E.164, solo si lo pudimos confirmar. Si no, null, y el teléfono tal como lo escribió el cliente va en phone_raw.
customer.phone_confidence declared (el cliente lo dio así), inferred (lo dedujimos del país de envío: probable, no seguro) o null.
customer.country El país de envío, ISO de dos letras.
order.name El número de pedido como lo ve el cliente.
products Los productos que se le piden. Los que ya reseñó en otra compra no vienen.
reminder Solo en review_request.reminder: el número de recordatorio.

review.created, review.published y review.rejected traen la reseña en data, con la misma forma que GET /reviews/{id}.

review.created
{
"id": "review.created:cm2k9c4zq0007",
"type": "review.created",
"created_at": "2026-10-18T21:14:09.530Z",
"shop": { "domain": "cumbre.myshopify.com" },
"data": {
"review_id": "cm2k9c4zq0007",
"kind": "product",
"status": "published",
"rating": 5,
"title": null,
"body": "La talla M me quedó justa y la tela es suavecita por dentro.",
"author_name": "María G.",
"verified_purchase": true,
"incentivized": false,
"source": "request_link",
"language": "es",
"has_media": true,
"media_count": 1,
"rejection_reason": null,
"submitted_at": "2026-10-18T21:14:08.912Z",
"published_at": "2026-10-18T21:14:08.950Z",
"edited_at": null,
"product": {
"id": "gid://shopify/Product/8123456789012",
"variant_id": "gid://shopify/ProductVariant/4512345678901",
"title": "Sudadera Arena",
"image_url": "https://cdn.shopify.com/s/files/1/0000/0001/files/sudadera-arena.jpg",
"url": "https://cumbre.mx/products/sudadera-arena"
},
"location": null,
"customer": { "name": "María", "email": "maria@correo.com", "phone": "+525512345678", "phone_confidence": "declared", "phone_raw": "55 1234 5678", "country": "MX" },
"order": { "id": "gid://shopify/Order/5812345678901", "name": "#1042" },
"request_id": "cm2k8x1f30001"
}
}

source dice de dónde vino: request_link (el enlace del pedido), qr_store (el QR de la tienda), qr_location (el de una sucursal), manual, import, api o pos. customer, order y request_id son null cuando la reseña no viene de un pedido.

Firmamos igual que la API: X-Signature es sha256= + el HMAC-SHA256 en hex, con tu secreto de firma (whsec_…), de {X-Timestamp}.{body crudo}.

  1. Lee el body crudo, antes de parsear el JSON.
  2. Rechaza si X-Timestamp tiene más de 5 minutos de diferencia con tu reloj.
  3. Calcula la firma y compárala en tiempo constante.
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const app = express();
app.post("/leadit", express.raw({ type: "application/json" }), (req, res) => {
const ts = req.get("X-Timestamp") ?? "";
const recibida = Buffer.from(req.get("X-Signature") ?? "");
const cuerpo = req.body.toString("utf8");
const vigente = /^\d+$/.test(ts) && Math.abs(Date.now() / 1000 - Number(ts)) <= 300;
const esperada = Buffer.from(
"sha256=" + createHmac("sha256", process.env.LEADIT_WHSEC).update(`${ts}.${cuerpo}`).digest("hex"),
);
if (!vigente || esperada.length !== recibida.length || !timingSafeEqual(esperada, recibida)) {
return res.sendStatus(401);
}
const aviso = JSON.parse(cuerpo);
res.sendStatus(200); // contesta primero; procesa después
if (aviso.test) return;
// ...encola aviso.id y aviso.data para mandarlo
});
  • Contesta con un 2xx en menos de 10 segundos. Lo que mandes en el body no lo leemos. Si tu proceso es lento, contesta primero y procesa después.
  • Cualquier otra cosa es un fallo: un 4xx, un 5xx, pasar de 10 segundos o una redirección (no las seguimos).
  • Reintentamos 5 veces: a 1 minuto, 5 minutos, 30 minutos, 2 horas y 12 horas del fallo anterior. X-Delivery-Attempt dice en qué intento vas.
  • Después del sexto intento, la entrega queda Sin entregar. La ves en Canales y la puedes reintentar a mano o con POST /webhook-deliveries/{id}/retry.
  • La entrega es “al menos una vez”: el mismo id puede llegar dos veces (un reintento que se cruzó con tu respuesta). Guarda los id que ya procesaste.
  • El endpoint tiene que ser https y público: no aceptamos direcciones de red privada.

En la app, Flujo → Despachar → Enviar prueba manda un review_request.due con los datos de una orden real que eliges y "test": true en el sobre. La app te dice qué código contestó tu endpoint.

Canales → Webhook saliente → Cambiar secreto genera uno nuevo. El anterior deja de servir en ese momento: ten listo tu sistema para ponerlo enseguida.