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.
- Create one public HTTPS POST endpoint that can read the exact raw request bytes.
- Generate a random signing secret of at least 32 characters and store it in your secret manager.
- Implement digest, timestamp, HMAC, and delivery-ID replay checks before CRM processing.
- Implement health.check, records.find, records.create, and records.update first.
- Add activities.create or tickets.create only when your CRM workflow uses them.
- Connect 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")
);
}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