Webhooks
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.
Eventos
Sección titulada «Eventos»| 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. |
La petición
Sección titulada «La petición»POST /tu-endpoint HTTP/1.1Content-Type: application/jsonUser-Agent: LeaditReviews-Webhook/1.0X-Event-Id: review_request.due:cm2k8x1f30001:1760630400000X-Event-Type: review_request.dueX-Delivery-Attempt: 1X-Timestamp: 1760630402X-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. |
Eventos de solicitud
Sección titulada «Eventos de solicitud»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.
{ "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. |
Eventos de reseña
Sección titulada «Eventos de reseña»review.created, review.published y review.rejected traen la reseña en data, con la misma forma que GET /reviews/{id}.
{ "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.
Verificar la firma
Sección titulada «Verificar la firma»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}.
- Lee el body crudo, antes de parsear el JSON.
- Rechaza si
X-Timestamptiene más de 5 minutos de diferencia con tu reloj. - 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});import hashlib, hmac, json, os, timefrom flask import Flask, request, abort
app = Flask(__name__)
@app.post("/leadit")def leadit(): ts = request.headers.get("X-Timestamp", "") recibida = request.headers.get("X-Signature", "") cuerpo = request.get_data(as_text=True)
if not ts.isdigit() or abs(time.time() - int(ts)) > 300: abort(401) esperada = "sha256=" + hmac.new(os.environ["LEADIT_WHSEC"].encode(), f"{ts}.{cuerpo}".encode(), hashlib.sha256).hexdigest() if not hmac.compare_digest(esperada, recibida): abort(401)
aviso = json.loads(cuerpo) if not aviso.get("test"): pass # ...encola aviso["id"] y aviso["data"] return "", 200<?php$cuerpo = file_get_contents('php://input');$ts = $_SERVER['HTTP_X_TIMESTAMP'] ?? '';$recibida = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$esperada = 'sha256=' . hash_hmac('sha256', "$ts.$cuerpo", getenv('LEADIT_WHSEC'));if (!ctype_digit($ts) || abs(time() - (int) $ts) > 300 || !hash_equals($esperada, $recibida)) { http_response_code(401); exit;}
$aviso = json_decode($cuerpo, true);http_response_code(200);if (empty($aviso['test'])) { // ...encola $aviso['id'] y $aviso['data']}Responder y reintentos
Sección titulada «Responder y reintentos»- 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-Attemptdice 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
idpuede llegar dos veces (un reintento que se cruzó con tu respuesta). Guarda losidque ya procesaste. - El endpoint tiene que ser
httpsy 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.
Cambiar el secreto
Sección titulada «Cambiar el secreto»Canales → Webhook saliente → Cambiar secreto genera uno nuevo. El anterior deja de servir en ese momento: ten listo tu sistema para ponerlo enseguida.