Webhooks
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.
Events
Section titled “Events”| 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. |
The request
Section titled “The request”POST /your-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…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. |
Request events
Section titled “Request events”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.
{ "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 events
Section titled “Review events”review.created, review.published and review.rejected carry the review in data, with the same shape as 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": "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.
Verify the signature
Section titled “Verify the signature”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}.
- Read the raw body, before parsing the JSON.
- Reject if
X-Timestampis more than 5 minutes off from your clock. - 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});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", "") received = request.headers.get("X-Signature", "") body = request.get_data(as_text=True)
if not ts.isdigit() or abs(time.time() - int(ts)) > 300: abort(401) expected = "sha256=" + hmac.new(os.environ["LEADIT_WHSEC"].encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, received): abort(401)
event = json.loads(body) if not event.get("test"): pass # ...queue event["id"] and event["data"] return "", 200<?php$body = file_get_contents('php://input');$ts = $_SERVER['HTTP_X_TIMESTAMP'] ?? '';$received = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', "$ts.$body", getenv('LEADIT_WHSEC'));if (!ctype_digit($ts) || abs(time() - (int) $ts) > 300 || !hash_equals($expected, $received)) { http_response_code(401); exit;}
$event = json_decode($body, true);http_response_code(200);if (empty($event['test'])) { // ...queue $event['id'] and $event['data']}Replying and retries
Section titled “Replying and retries”- 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-Attempttells 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
idcan arrive twice (a retry that crossed paths with your reply). Store theids you’ve already processed. - The endpoint must be
httpsand public: we don’t accept private network addresses.
Testing
Section titled “Testing”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.
Changing the secret
Section titled “Changing the secret”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.