Custom CRM Webhook

Push contacts and job-completed events from Zapier, Make, n8n, or your own code — no vendor CRM required

Overview

The Custom webhook integration is a CRM connection with no CRM behind it. Instead of us pulling data from a vendor’s API, your systems push two kinds of events to a unique endpoint:

  • contact.upserted — create or update a contact.
  • job.completed — signal that a service was rendered for a customer. If the connection has an auto-enrollment rule for this event, the named customer is enrolled in your review campaign, subject to every enrollment guard (opt-outs, already-reviewed, cooldowns, plan limits).

Use it from Zapier, Make, n8n, a field-service tool we don’t integrate with natively, or fifty lines of your own code.

Connect it under Settings → Integrations → Add CRM → Custom webhook. There are no credentials to paste — after connecting, the integration card shows the two things your sender needs:

  • Endpoint URLhttps://<your-app-host>/webhooks/crm/custom/<connection_id>
  • Webhook secret — revealed on demand from the card; used for authentication in both modes below.

Payload

POST a JSON body in this envelope (version 1):

{
  "version": "1",
  "event": "contact.upserted",
  "id": "3f2a9c6e-1b7d-4e5f-9a80-2c4d6e8f0a1b",
  "contact": {
    "external_id": "cus_1234",
    "first_name": "Sarah",
    "last_name": "Miller",
    "email": "sarah.miller@example.com",
    "phone": "+15550101234"
  },
  "job": {
    "external_id": "job_789",
    "completed_at": "2026-07-16T15:04:05Z",
    "service_type": "HVAC tune-up"
  }
}

Field rules:

  • version — optional; defaults to "1". Any other value is rejected so a future format change fails loudly instead of being half-read.
  • event — required: "contact.upserted" or "job.completed".
  • id — optional but strongly recommended: your idempotency key. See Retries and deduplication.
  • contact.external_idrequired on both event types. It is the stable key contacts are stored under (crm_external_id), and it is how a job.completed names its customer inline — there is no remote CRM for us to look the customer up in, so the event must carry the link itself. Payloads without it are rejected with 400.
  • contact name/email/phone fields — on contact.upserted the block is applied verbatim as the contact’s record, so send the full record each time. On job.completed the block may be a bare external_id reference; a bare reference never blanks out fields you pushed earlier.
  • job — optional, job.completed only. completed_at must be RFC3339 when present.

A job.completed for a customer you never pushed still works if it carries the full contact block — the contact is created first, then enrollment is evaluated.

Authentication

Every request must authenticate with the connection’s webhook secret in one of two ways. If both headers are present, the signature is authoritative.

Send an X-ReviewMe-Signature header:

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

We verify the MAC in constant time and reject timestamps more than 5 minutes from our clock (replay protection).

This is deliberately the same scheme our outbound webhook blocks use — see Webhooks, which shows the verifying side. That guide teaches you to verify t=/v1= headers we send; this one asks you to produce them. Same math, opposite direction — if you have implemented one, you have implemented both.

Sign the exact bytes you send. Serialize the JSON once, sign those bytes, send those bytes.

Node.js

const crypto = require("crypto");

function signedHeaders(secret, rawBody) {
  const t = Math.floor(Date.now() / 1000).toString();
  const v1 = crypto
    .createHmac("sha256", secret)
    .update(t + "." + rawBody)
    .digest("hex");
  return {
    "Content-Type": "application/json",
    "X-ReviewMe-Signature": `t=${t},v1=${v1}`,
  };
}

// Usage:
const rawBody = JSON.stringify({
  event: "job.completed",
  id: crypto.randomUUID(),
  contact: { external_id: "cus_1234", first_name: "Sarah", email: "sarah@example.com" },
  job: { external_id: "job_789", completed_at: new Date().toISOString() },
});

fetch("https://your-app-host/webhooks/crm/custom/<connection_id>", {
  method: "POST",
  headers: signedHeaders(process.env.REVIEWME_WEBHOOK_SECRET, rawBody),
  body: rawBody,
});

Python

import hashlib, hmac, json, time, uuid
import urllib.request

def signed_headers(secret: str, raw_body: bytes) -> dict:
    t = str(int(time.time()))
    v1 = hmac.new(
        secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256
    ).hexdigest()
    return {
        "Content-Type": "application/json",
        "X-ReviewMe-Signature": f"t={t},v1={v1}",
    }

raw_body = json.dumps({
    "event": "job.completed",
    "id": str(uuid.uuid4()),
    "contact": {"external_id": "cus_1234", "first_name": "Sarah", "email": "sarah@example.com"},
    "job": {"external_id": "job_789", "completed_at": "2026-07-16T15:04:05Z"},
}).encode()

req = urllib.request.Request(
    "https://your-app-host/webhooks/crm/custom/<connection_id>",
    data=raw_body,
    headers=signed_headers("your-webhook-secret", raw_body),
    method="POST",
)
urllib.request.urlopen(req)

Mode B — bearer token (no-code senders)

If your sender can set a static header but cannot compute an HMAC, send the secret as a bearer token:

Authorization: Bearer <webhook secret>

The comparison is constant-time. Prefer Mode A when you control the code — a signed request can’t be replayed outside the tolerance window, a stolen bearer request can be resent as-is until you rotate the secret.

curl

curl -X POST "https://your-app-host/webhooks/crm/custom/<connection_id>" \
  -H "Authorization: Bearer $REVIEWME_WEBHOOK_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "contact.upserted",
    "id": "'"$(uuidgen)"'",
    "contact": {
      "external_id": "cus_1234",
      "first_name": "Sarah",
      "last_name": "Miller",
      "email": "sarah.miller@example.com",
      "phone": "+15550101234"
    }
  }'

Responses

Status Meaning
204 Accepted — stored and queued for processing. Also returned for a duplicate id (the duplicate is dropped).
400 Payload rejected: malformed JSON, unknown event or version, missing contact.external_id, non-RFC3339 completed_at.
401 Authentication failed: bad signature, stale timestamp, wrong bearer token, or no auth header.
404 Unknown connection id (or the connection isn’t a custom-webhook connection).
413 Body over 1 MiB.

Processing is asynchronous: a 204 means the event was accepted, not that the contact is already visible. The integration card’s Recent webhook events panel shows each event and whether processing succeeded.

Retries and deduplication

  • Send retries freely. If you didn’t get a 2xx, resend the same body (same id). Delivery is expected to be at-least-once.
  • id gives you exact dedupe. Events are stored with your id as their idempotency key under a unique index, so a retried delivery with the same id is dropped before processing and still answered 204. Make it globally unique — a UUID per event, or a key prefixed with something only you would use (acme-job-789-completed). Don’t use bare counters like "1".
  • Without id, dedupe is best-effort. Each delivery is processed. That is still safe — contact.upserted is an idempotent upsert, and job.completed re-evaluation is absorbed by the enrollment guards (an in-progress run, the reviewed gate, and the cooldowns all make the second pass a recorded skip) — but your event history will show duplicates.
  • Enrollment never double-fires. Even a job.completed replayed past the dedupe (different id, same job) cannot enroll the same contact into a campaign twice while a run is active or within the cooldown window.

Use with Zapier

  1. In ReviewMe, connect Settings → Integrations → Add CRM → Custom webhook. From the integration card, copy the Endpoint URL and reveal + copy the Webhook secret.
  2. In Zapier, build a Zap triggered by your source app (e.g. your field-service tool’s “Job completed”). Add a Webhooks by Zapier → Custom Request action (Custom Request, not plain POST, so you can set headers):
    • Method: POST
    • URL: the Endpoint URL
    • Data: the JSON payload above, mapping your source fields into contact (and job for completions). Include an id mapped from your source event’s unique id.
    • Headers: Authorization: Bearer <webhook secret> and Content-Type: application/json
  3. Test the Zap, then check the integration card’s Recent webhook events — the event should appear and flip to processed.
  4. To auto-enroll on completions, add a rule on the integration card targeting your campaign with the Job completed trigger event.

Make and n8n work the same way; n8n’s HTTP Request node can also implement Mode A with a small function node using the Node.js snippet above.

Toggles that matter

  • Process inbound events (the connection’s sync toggle) — off means deliveries are still authenticated and recorded, but nothing is imported or enrolled.
  • There is no “Sync now” and no push-back for this integration: there is no remote to pull from or write to.
Built with goilerplate