# 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](/help/integrations/set-up-a-project-webhook) or a [form webhook](/help/integrations/form-webhooks), 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 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:

```js
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:

| 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:

```json
{
  "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'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:

| 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.
