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 URL —
https://<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_id— required on both event types. It is the stable key contacts are stored under (crm_external_id), and it is how ajob.completednames 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 with400.contactname/email/phone fields — oncontact.upsertedthe block is applied verbatim as the contact’s record, so send the full record each time. Onjob.completedthe block may be a bareexternal_idreference; a bare reference never blanks out fields you pushed earlier.job— optional,job.completedonly.completed_atmust 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.
Mode A — signed requests (recommended)
Send an X-ReviewMe-Signature header:
X-ReviewMe-Signature: t=1752582896,v1=3f8a...c1
tis the unix timestamp you signed at.v1isHMAC-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 (sameid). Delivery is expected to be at-least-once. idgives you exact dedupe. Events are stored with youridas their idempotency key under a unique index, so a retried delivery with the sameidis dropped before processing and still answered204. 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.upsertedis an idempotent upsert, andjob.completedre-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.completedreplayed past the dedupe (differentid, same job) cannot enroll the same contact into a campaign twice while a run is active or within the cooldown window.
Use with Zapier
- In ReviewMe, connect Settings → Integrations → Add CRM → Custom webhook. From the integration card, copy the Endpoint URL and reveal + copy the Webhook secret.
- 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(andjobfor completions). Include anidmapped from your source event’s unique id. - Headers:
Authorization: Bearer <webhook secret>andContent-Type: application/json
- Method:
- Test the Zap, then check the integration card’s Recent webhook events — the event should appear and flip to processed.
- 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.