Skip to content

Webhooks

Plus and Pro

When something happens, Leadit Reviews sends a POST with JSON to the URL you set in Channels → Outgoing webhook. It’s the basis of your own channel: you receive review_request.due and send the message.

Event In the app When it arrives
review_request.due Request ready to send The day to ask for the review has come. Listening to it makes you the channel: Leadit Reviews no longer writes to the customer.
review_request.reminder Time to send a reminder The customer hasn’t replied. It includes reminder with the reminder number (1, 2…).
review_request.responded Customer finished the form They reviewed or skipped each product in their order. Reminders are turned off.
review.created New review A review came in through the form, the QR or the API. Imported ones don’t notify.
review.published Review published A review was published.
review.rejected Review rejected A review was rejected. It includes rejection_reason.
POST /your-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…

All events arrive in the same envelope:

Field What it is
id Unique per occurrence. Same as X-Event-Id. Use it to avoid processing the same notice twice.
type The event.
created_at When we built it.
shop.domain The store (cumbre.myshopify.com).
data The content: a request or a review (below).
test Only on Send test deliveries: true. Don’t act on them.

review_request.due, review_request.reminder and review_request.responded carry the request in data, with the same shape as the response of 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@example.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"
}
]
}
}
data field What it is
request_id The request’s id. Use it in the API (/review-requests/{id}/…).
url The full link to the form.
token What goes at the end of url, after /r/. It’s the variable of a WhatsApp template’s dynamic URL button: https://reviews.leadit.tech/r/{{1}}.
status scheduled, due, paused, dispatched, opened, partial, responded, expired, cancelled or failed.
locale es or en. null: the language the store chose for its form.
expires_at How long the link works.
reminders_sent How many reminders have been sent.
customer.name The customer’s name. It can be null.
customer.phone In E.164, only if we could confirm it. If not, null, and the phone as the customer typed it goes in phone_raw.
customer.phone_confidence declared (the customer gave it that way), inferred (we deduced it from the shipping country: likely, not certain) or null.
customer.country The shipping country, two-letter ISO.
order.name The order number as the customer sees it.
products The products they’re asked about. Those they already reviewed in another purchase aren’t included.
reminder Only in review_request.reminder: the reminder number.

review.created, review.published and review.rejected carry the review in data, with the same shape as 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": "Size M fits just right and the fabric is really soft inside.",
"author_name": "María G.",
"verified_purchase": true,
"incentivized": false,
"source": "request_link",
"language": "en",
"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@example.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 says where it came from: request_link (the order’s link), qr_store (the store’s QR), qr_location (a location’s QR), manual, import, api or pos. customer, order and request_id are null when the review doesn’t come from an order.

We sign the same way as the API: X-Signature is sha256= + the HMAC-SHA256 in hex, with your signing secret (whsec_…), of {X-Timestamp}.{raw body}.

  1. Read the raw body, before parsing the JSON.
  2. Reject if X-Timestamp is more than 5 minutes off from your clock.
  3. Compute the signature and compare it in constant time.
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 received = Buffer.from(req.get("X-Signature") ?? "");
const body = req.body.toString("utf8");
const fresh = /^\d+$/.test(ts) && Math.abs(Date.now() / 1000 - Number(ts)) <= 300;
const expected = Buffer.from(
"sha256=" + createHmac("sha256", process.env.LEADIT_WHSEC).update(`${ts}.${body}`).digest("hex"),
);
if (!fresh || expected.length !== received.length || !timingSafeEqual(expected, received)) {
return res.sendStatus(401);
}
const event = JSON.parse(body);
res.sendStatus(200); // reply first; process afterwards
if (event.test) return;
// ...queue event.id and event.data to send it
});
  • Reply with a 2xx in under 10 seconds. We don’t read whatever you send in the body. If your process is slow, reply first and process afterwards.
  • Anything else is a failure: a 4xx, a 5xx, going over 10 seconds or a redirect (we don’t follow them).
  • We retry 5 times: 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours after the previous failure. X-Delivery-Attempt tells you which attempt you’re on.
  • After the sixth attempt, the delivery is left Not delivered. You see it in Channels and can retry it by hand or with POST /webhook-deliveries/{id}/retry.
  • Delivery is “at least once”: the same id can arrive twice (a retry that crossed paths with your reply). Store the ids you’ve already processed.
  • The endpoint must be https and public: we don’t accept private network addresses.

In the app, Flow → Dispatch → Send test sends a review_request.due with the data of a real order you pick and "test": true in the envelope. The app tells you which code your endpoint replied with.

Channels → Outgoing webhook → Change secret generates a new one. The previous one stops working at that moment: have your system ready to switch to it right away.