Build
CRM Webhook
Receive mapped Concierge records through a signed, versioned middleware contract.
Build the middleware endpoint first
CRM Webhook is the adapter for a CRM without a native Concierge connector. Your HTTPS endpoint translates a stable Concierge operation into the CRM's API and returns the CRM record identity.
- 1Create one public HTTPS POST endpoint that can read the exact raw request bytes.
- 2Generate a random signing secret of at least 32 characters and store it in your secret manager.
- 3Implement digest, timestamp, HMAC, and delivery-ID replay checks before CRM processing.
- 4Implement health.check, records.find, records.create, and records.update first.
- 5Add activities.create or tickets.create only when your CRM workflow uses them.
- 6Connect the endpoint in Concierge, map fields, configure sync rules, then run Test & health.
| Operation | Expected middleware action |
|---|---|
| health.check | Verify the endpoint and downstream CRM are available. |
| records.find | Find one record using envelope.record.identity. |
| records.create | Create a record and return its externalId. |
| records.update | Update envelope.record.externalId and return it. |
| activities.create | Attach the approved source event as a CRM activity. |
| tickets.create | Create a CRM ticket when the active rule requests it. |
Verify every delivery
Read the raw body before JSON parsing. Confirm the SHA-256 digest, reject timestamps outside a five-minute window, atomically claim the delivery ID, and compare the HMAC in constant time.
| Header | Value |
|---|---|
| X-Concierge-Event | The requested operation, such as records.create |
| X-Concierge-Schema-Version | 1.0 |
| X-Concierge-Timestamp | ISO timestamp used in the signature input |
| X-Concierge-Delivery-Id | Stable identity used for replay protection |
| X-Concierge-Content-SHA256 | Lowercase SHA-256 hex of the exact raw body |
| X-Concierge-Signature | v1=<HMAC-SHA256 hex> |
| Authorization | Optional Bearer token configured by the customer |
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
export function verifyConciergeCrm({ rawBody, headers, secret }) {
const timestamp = headers["x-concierge-timestamp"];
const deliveryId = headers["x-concierge-delivery-id"];
const suppliedDigest = headers["x-concierge-content-sha256"];
const suppliedSignature = headers["x-concierge-signature"]?.replace(/^v1=/, "");
const digest = createHash("sha256").update(rawBody).digest("hex");
if (digest !== suppliedDigest) return false;
const age = Math.abs(Date.now() - Date.parse(timestamp));
if (!Number.isFinite(age) || age > 5 * 60_000) return false;
const expected = createHmac("sha256", secret)
.update(timestamp).update(".")
.update(deliveryId).update(".")
.update(rawBody)
.digest("hex");
if (!/^[a-f0-9]{64}$/i.test(suppliedSignature || "")) return false;
return timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(suppliedSignature, "hex")
);
}The signature input is timestamp + '.' + deliveryId + '.' + exactRawBody. Do not re-stringify parsed JSON. Store the delivery-ID claim atomically for at least the timestamp window.
Handle the envelope and response
The tenant block identifies the owning installation, account, and site. Use all three when scoping your stored connection. record.values contains only the customer's active mapping.
- For records.find, return ok, found, and record when found.
- For records.create and records.update, ok must be true and record.externalId must be present.
- Use the supplied idempotencyKey when the CRM supports idempotent creates.
- Return 2xx only after the requested operation has been accepted.
- Keep connection secrets and customer field values out of application logs.
records.create request
{
"schemaVersion": "1.0",
"operation": "records.create",
"deliveryId": "crm:delivery:01J...",
"sentAt": "2026-07-29T20:00:00.000Z",
"tenant": {
"installationId": "mkinstall_...",
"accountId": "account_...",
"siteId": "site_..."
},
"record": {
"object": "contacts",
"identity": {"email": "customer@example.com"},
"values": {"email": "customer@example.com", "first_name": "Taylor"},
"idempotencyKey": "crm-write:01J..."
}
}Successful create response
{
"ok": true,
"providerRequestId": "request_123",
"record": {
"object": "contacts",
"externalId": "contact_42",
"externalUrl": "https://crm.example.com/contacts/contact_42"
}
}Next guide
Routing webhooks