Webhooks

Fire a signed payload to Zapier, Make, or any URL when a contact reaches a webhook block

Overview

A webhook block POSTs a signed, versioned JSON payload to a URL you choose the moment a contact reaches that step in a campaign. It is the generic building block behind Zapier (“Catch Hook”), Make, n8n, and your own endpoints — no native app required.

Webhook blocks are available on the Pro plan. (This is the in-campaign automation block; the account-level public API and org-wide webhooks are a separate, Enterprise feature that is still on the roadmap.)

Three things make a webhook block different from a message block, and all of them are intentional:

  • It is not a message. There is no quiet-hours hold and no “Reply STOP” footer — it is automation you direct, like the report block.
  • It exports contact details to a third party you choose. You are the data controller for whatever you send onward.
  • Delivery is best-effort, at-least-once. A failing endpoint is retried with backoff, but a broken endpoint never blocks the rest of the campaign — after the final retry the contact advances anyway.

Payload

Every delivery is a POST with a JSON body in this envelope:

{
  "version": "1",
  "event": "campaign.block_reached",
  "sent_at": "2026-07-15T12:34:56Z",
  "data": {
    "organization": { "id": "9f1b2c3d-...", "name": "Acme Plumbing" },
    "campaign": { "id": "4e5f6a7b-...", "name": "Post-job review ask" },
    "contact": {
      "id": "8c9d0e1f-...",
      "first_name": "Sarah",
      "last_name": "Miller",
      "email": "sarah.miller@example.com",
      "phone": "+15550101234"
    },
    "enrollment": {
      "status": "in_progress",
      "section": "chase"
    },
    "block": { "id": "2a3b4c5d-..." }
  }
}

Field notes:

  • version — the envelope schema version. Switch on it; it only changes on a breaking change.
  • id fields — plain UUIDs (no type prefix). They are stable: the same contact keeps the same id across deliveries, so use them for dedup and mapping.
  • event — currently always campaign.block_reached.
  • test — present and true only for a payload sent from the builder’s Send test payload button. Real deliveries omit it. Use it to avoid acting on test fires.
  • sent_at — RFC 3339 UTC send time.
  • data.enrollment.sectionchase while the contact is still being asked, on_review once a review has been confirmed and the contact fell through to the completion section.
  • data.enrollment.reviewed_at — present only when the review has been confirmed (typically in the on_review section); omitted otherwise, as in the example above.

The payload deliberately carries a subset of each record — internal fields (opt-out flags, consent audit, CRM metadata) never cross the tenant boundary.

Verifying the signature

Every delivery carries an X-ReviewMe-Signature header so you can confirm it really came from us and was not replayed:

X-ReviewMe-Signature: t=1752582896,v1=3f8a...c1
  • t is the unix timestamp the payload was signed at.
  • v1 is HMAC-SHA256(secret, "<t>" + "." + rawBody), hex-encoded.

The secret is your organization’s webhook signing secret (a whsec_-prefixed string) — find it under Settings → Team → Webhook signing secret. The entire string, prefix included, is the HMAC key.

To verify a delivery:

  1. Recompute the MAC over t + "." + rawBody, where rawBody is the exact bytes you received — do not re-serialize the JSON first.
  2. Compare your MAC to v1 in constant time.
  3. Reject the delivery if t is more than a few minutes (e.g. 5) from your clock — that window is your replay protection.

Node.js

const crypto = require("crypto");

function verify(secret, header, rawBody, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  const { t, v1 } = parts;
  if (!t || !v1) throw new Error("malformed signature");

  const expected = crypto
    .createHmac("sha256", secret)
    .update(t + "." + rawBody)
    .digest("hex");

  const a = Buffer.from(v1);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    throw new Error("signature mismatch");
  }

  const age = Math.abs(Math.floor(Date.now() / 1000) - parseInt(t, 10));
  if (age > toleranceSeconds) throw new Error("timestamp outside tolerance");

  return true;
}

Python

import hashlib, hmac, time

def verify(secret, header, raw_body, tolerance_seconds=300):
    parts = dict(kv.split("=", 1) for kv in header.split(","))
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1:
        raise ValueError("malformed signature")

    expected = hmac.new(
        secret.encode(), (t + "." + raw_body).encode(), hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(v1, expected):
        raise ValueError("signature mismatch")

    if abs(int(time.time()) - int(t)) > tolerance_seconds:
        raise ValueError("timestamp outside tolerance")

    return True

Rotating the secret

Rotating issues a new secret immediately and invalidates the old one. Any receiver still verifying against the previous secret will start rejecting deliveries until you update it there, so rotate only when you need to and update your receivers promptly.

Use with Zapier

  1. In Zapier, create a Zap and choose Webhooks by Zapier → Catch Hook as the trigger. Copy the custom webhook URL Zapier gives you.
  2. In ReviewMe, open your campaign in the builder, add a Webhook block where you want it to fire, and paste the URL into Endpoint URL. Save the block.
  3. Click Send test payload. Zapier’s Catch Hook receives the sample payload — use it to map the fields in the rest of your Zap. The toast in ReviewMe shows the HTTP status your endpoint returned.
  4. (Optional but recommended) Add a Zapier Code step that verifies X-ReviewMe-Signature with your signing secret before the Zap acts, using the example above.

Make, n8n, and custom endpoints work the same way: paste the receiving URL into the block and verify the signature on your side.

Built with goilerplate