Integrations and domainsWebhooks

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:

HeaderValue
Content-Typeapplication/json
User-AgentReTestimonial-ProjectWebhooks/1.0
X-Signaturet=<unix seconds>,v1=<signature> (see below)
X-ReTestimonial-EventThe event, such as testimonial.approved
X-ReTestimonial-DeliveryThe delivery's id, the same as the body's id
Idempotency-KeyThe 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 new t.
  • v1: an HMAC-SHA256 of t, 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.timingSafeEqual does, 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 as Idempotency-Key and X-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, mutationGroupId and 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:

FieldWhat it holds
idThe delivery's id.
eventThe event name.
eventVersionThe version of this format, a date such as 2026-07-16.
timestampWhen the event happened, in ISO 8601 (UTC).
mutationGroupIdShared by the events one change produced: for example testimonial.created and testimonial.submitted from one form submission, or every event of one bulk action.
sourceRawWhere the testimonial came from, as stored, such as hosted for a form or google.
sourceCategoryThat source, grouped: form, manual, social_media, media_platform, review_platform, import, extension, duplicate, system or unknown.
dataThe 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

Eventdata
testimonial.created, testimonial.approved, testimonial.updated, testimonial.archived, testimonial.spamtestimonial and project
testimonial.submittedtestimonial, project, form and submission
testimonial.deletedtestimonialId, project and deletionReason (the testimonial itself is gone)

project holds the project's id, name and slug.

The testimonial

FieldWhat it holds
id, projectIdThe testimonial's id and its project's.
typeTEXT, VIDEO or AUDIO.
statusPENDING, APPROVED or REJECTED. Archived and spam are the separate isArchived and isSpam flags.
contentThe testimonial's text. It can contain simple HTML formatting.
ratingThe star rating, or null.
authorName, authorEmail, authorTitle, authorCompanyWho wrote it. Everything but the name can be null.
authorAvatarThe author's photo as a full web address, or null.
socialProfilesThe author's social profiles, each a platform and a value.
customFieldsYour custom fields, each with its id, label, type, raw value and a ready-to-print displayValue.
customFieldValuesThe same raw values, keyed by field id, without labels.
tagsThe testimonial's tags.
isArchived, isSpamWhether it's archived, and whether it's marked as spam.
sourceRaw, sourceMetadataIts source, and extra details from that source (or null).
videoThumbnail, videoDuration, muxPlaybackId, audioDurationDetails of a video or audio testimonial; null for text. videoThumbnail is a full web address.
createdAt, updatedAtWhen it was created and last changed, in ISO 8601.

Form submissions

testimonial.submitted adds two blocks:

  • form: the form's id, name and slug.
  • submission: its id; isNegativeFeedback, which is true when the form treated the answer as negative feedback because of a low rating; responses, the raw answers keyed by question or page id; and startedAt, completedAt and completionTimeSeconds, any of which can be null.

Deleted testimonials

deletionReason says how the testimonial was deleted:

ValueMeaning
manual_deleteDeleted on its own.
bulk_deleteDeleted with others you selected in the inbox.
source_bulk_deleteDeleted with other reviews from a review platform, such as when you remove a connected business or delete its reviews in bulk.
import_rollbackRemoved by undoing an import.
project_spam_actionDeleted from the spam list.
spam_cleanupRemoved by the automatic spam cleanup.
unknownAny 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?

On this page