Webhook signatures, headers and payloads
How ReTestimonial signs webhook requests, how to verify the X-Signature header in Node.js, and what each event's JSON payload contains.
Every webhook ReTestimonial sends, whether from a project webhook or a form webhook, uses the same headers, the same signature and the same JSON envelope. This page is for whoever builds the receiver.
The request
Each delivery is an HTTPS POST with a JSON body and these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | ReTestimonial-ProjectWebhooks/1.0 |
X-Signature | t=<unix seconds>,v1=<signature> (see below) |
X-ReTestimonial-Event | The event, such as testimonial.approved |
X-ReTestimonial-Delivery | The delivery's id, the same as the body's id |
Idempotency-Key | The delivery's id again |
Answer with any 2xx status code within 10 seconds. Anything else counts as a failure and is tried again after 1 minute, 10 minutes and 1 hour, four attempts in all: another status code, no answer in time, a connection error, or a redirect (redirects aren't followed). The delivery log keeps your response's status code, headers (without cookies or authorization headers) and the start of its body, to help you debug.
How the signature works
The X-Signature header has two parts, separated by a comma:
t: when this attempt was sent, in Unix seconds. Every retry gets a newt.v1: an HMAC-SHA256 oft, a full stop (.) and the raw request body, keyed with the endpoint's signing secret and written as lowercase hex.
Use the whole secret as the key, including its whsec_ prefix, as plain text: don't strip the prefix or decode it. Sign the body exactly as it arrived, before any JSON parsing.
Verify a request in Node.js
This Express receiver checks the signature and the timestamp before it trusts the body:
const crypto = require("node:crypto");
const express = require("express");
const app = express();
const SECRET = process.env.RETESTIMONIAL_WEBHOOK_SECRET; // the whole whsec_... value
const TOLERANCE_SECONDS = 5 * 60;
// express.raw keeps the body as bytes, which is what the signature covers.
app.post("/webhooks/retestimonial", express.raw({ type: "application/json" }), (req, res) => {
const parts = Object.fromEntries(
(req.get("X-Signature") || "").split(",").map((part) => {
const index = part.indexOf("=");
return [part.slice(0, index).trim(), part.slice(index + 1).trim()];
})
);
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${parts.t}.`)
.update(req.body)
.digest("hex");
const received = Buffer.from(parts.v1 || "", "utf8");
const wanted = Buffer.from(expected, "utf8");
const signatureOk = received.length === wanted.length && crypto.timingSafeEqual(received, wanted);
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
const fresh = Number.isFinite(age) && age <= TOLERANCE_SECONDS;
if (!signatureOk || !fresh) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// Skip event.id if you've handled it already, then process the event.
res.sendStatus(200);
});Three things to get right in any language:
- Use the raw body. If a JSON parser reads the request first and you sign the re-encoded result, the signature won't match.
- Compare in constant time, as
crypto.timingSafeEqualdoes, rather than with===. - Answer quickly. Do slow work after you respond, so a busy receiver doesn't run past the time limit and cause retries.
Reject old requests
Compare t with your own clock and reject requests older than a few minutes (five is common). That stops anyone who captured a request from sending it to you again later. Check t, not the body's timestamp: timestamp is when the event happened and stays the same on every retry, so a retry an hour later would look stale. Keep your server's clock in sync.
Handle duplicates and order
Deliveries arrive at least once: a retry or a replay can bring the same delivery again.
- The body's
id(also sent asIdempotency-KeyandX-ReTestimonial-Delivery) is unique to one event on one endpoint, and stays the same on every retry and replay. Store the ids you've handled and skip repeats. - Two endpoints that receive the same event get different ids. If both point at one receiver, the same
event,mutationGroupIdand testimonial id mean the same event. - Order isn't guaranteed: a retried delivery can arrive after a later event. Each payload is a snapshot of the testimonial when the event happened, with its
updatedAt, so you can ignore a snapshot older than one you already have. A retry resends the same snapshot.
The payload
Every body has the same envelope:
| Field | What it holds |
|---|---|
id | The delivery's id. |
event | The event name. |
eventVersion | The version of this format, a date such as 2026-07-16. |
timestamp | When the event happened, in ISO 8601 (UTC). |
mutationGroupId | Shared by the events one change produced: for example testimonial.created and testimonial.submitted from one form submission, or every event of one bulk action. |
sourceRaw | Where the testimonial came from, as stored, such as hosted for a form or google. |
sourceCategory | That source, grouped: form, manual, social_media, media_platform, review_platform, import, extension, duplicate, system or unknown. |
data | The event's details, below. |
A testimonial approved in the inbox, for example:
{
"id": "Vx3kQ9pLr2TzN8aYc0bWd",
"event": "testimonial.approved",
"eventVersion": "2026-07-16",
"timestamp": "2026-09-29T10:15:02.114Z",
"mutationGroupId": "Uakgb4J5m9gs0JDMbcJqL",
"sourceRaw": "hosted",
"sourceCategory": "form",
"data": {
"testimonial": {
"id": "c8XyVw2nPq5LmR7tKd0Ze",
"projectId": "p4TnB7wQx9LkD2mVr8sYa",
"type": "TEXT",
"status": "APPROVED",
"content": "Ordering for our office every Friday is now the easiest part of the week.",
"rating": 5,
"authorName": "Dana Reyes",
"authorEmail": "dana@example.com",
"authorTitle": "Office Manager",
"authorCompany": "Northwind",
"authorAvatar": null,
"socialProfiles": [],
"customFieldValues": { "fQ2wE8rT": "Catering" },
"customFields": [
{ "id": "fQ2wE8rT", "label": "Service", "type": "dropdown", "value": "Catering", "displayValue": "Catering" }
],
"tags": ["homepage"],
"isArchived": false,
"isSpam": false,
"sourceRaw": "hosted",
"sourceMetadata": null,
"videoThumbnail": null,
"muxPlaybackId": null,
"audioDuration": null,
"videoDuration": null,
"createdAt": "2026-09-28T16:40:11.902Z",
"updatedAt": "2026-09-29T10:15:01.877Z"
},
"project": { "id": "p4TnB7wQx9LkD2mVr8sYa", "name": "Brightside Bakery", "slug": "brightside-bakery" }
}
}What data holds for each event
| Event | data |
|---|---|
testimonial.created, testimonial.approved, testimonial.updated, testimonial.archived, testimonial.spam | testimonial and project |
testimonial.submitted | testimonial, project, form and submission |
testimonial.deleted | testimonialId, project and deletionReason (the testimonial itself is gone) |
project holds the project's id, name and slug.
The testimonial
| Field | What it holds |
|---|---|
id, projectId | The testimonial's id and its project's. |
type | TEXT, VIDEO or AUDIO. |
status | PENDING, APPROVED or REJECTED. Archived and spam are the separate isArchived and isSpam flags. |
content | The testimonial's text. It can contain simple HTML formatting. |
rating | The star rating, or null. |
authorName, authorEmail, authorTitle, authorCompany | Who wrote it. Everything but the name can be null. |
authorAvatar | The author's photo as a full web address, or null. |
socialProfiles | The author's social profiles, each a platform and a value. |
customFields | Your custom fields, each with its id, label, type, raw value and a ready-to-print displayValue. |
customFieldValues | The same raw values, keyed by field id, without labels. |
tags | The testimonial's tags. |
isArchived, isSpam | Whether it's archived, and whether it's marked as spam. |
sourceRaw, sourceMetadata | Its source, and extra details from that source (or null). |
videoThumbnail, videoDuration, muxPlaybackId, audioDuration | Details of a video or audio testimonial; null for text. videoThumbnail is a full web address. |
createdAt, updatedAt | When it was created and last changed, in ISO 8601. |
Form submissions
testimonial.submitted adds two blocks:
form: the form'sid,nameandslug.submission: itsid;isNegativeFeedback, which istruewhen the form treated the answer as negative feedback because of a low rating;responses, the raw answers keyed by question or page id; andstartedAt,completedAtandcompletionTimeSeconds, any of which can benull.
Deleted testimonials
deletionReason says how the testimonial was deleted:
| Value | Meaning |
|---|---|
manual_delete | Deleted on its own. |
bulk_delete | Deleted with others you selected in the inbox. |
source_bulk_delete | Deleted with other reviews from a review platform, such as when you remove a connected business or delete its reviews in bulk. |
import_rollback | Removed by undoing an import. |
project_spam_action | Deleted from the spam list. |
spam_cleanup | Removed by the automatic spam cleanup. |
unknown | Any other way. |
Test events
Send test (Send Test Event on a form webhook) sends a real, signed request with sample data. Its ids start with test_ (the delivery id, the mutationGroupId and the testimonial's id), the author is "Test User" and sourceCategory is manual. A testimonial.deleted test has the deleted shape, and a testimonial.submitted test includes form and submission (with the real form, for a form webhook). Skip test_ ids in production, or send them to a log.
Form webhooks
Form webhooks use exactly this format and signature, with their own signing secret. They send testimonial.submitted for their own form's submissions only.
Was this page helpful?
Set up a project webhook
Add a webhook endpoint in your project's Developer settings, copy its signing secret, send a test, and retry or replay failed deliveries.
Send a form's submissions to a webhook
Send each testimonial submitted through one form to your own server or automation tool as a signed JSON request, and test the connection.