CRM Webhook Integrations Cookbook
Webhooks are how the rest of your stack reacts to CRM changes without polling. This is the practical guide to Relm's outbound webhooks: register an endpoint, verify the signature, survive retries, and wire three integrations you will actually build - a Slack alert, a billing sync, and a warehouse feed.
Who this is for
Anyone whose CRM needs to trigger work in another system the moment something changes - a deal is won, a contact is updated, a new opportunity appears. If you have been polling GET /v1/deals on a cron to notice changes, webhooks replace that with a push: Relm calls you, signed and retried, within seconds of the event.
The model: events, subscriptions, signed deliveries
Relm emits five events: contact.created, contact.updated, deal.created, deal.updated, deal.stage_changed. You register a webhook - an https URL plus the events it wants - and Relm sends a signed POST for each matching event. A deal created with a stage emits both deal.created and deal.stage_changed. Bulk imports via POST /v1/batch are event-silent by design, so an import does not stampede your endpoint.
Register one and keep the returned secret - it is shown once, like an API key:
curl https://api.relmcrm.com/v1/webhooks \
-H "Authorization: Bearer relm_live_..." -H "Content-Type: application/json" \
-d '{ "url": "https://your.app/relm/webhook", "events": ["deal.stage_changed"] }'
# -> { "id": "wh_...", "secret": "whsec_...", "events": ["deal.stage_changed"], ... }
An agent can do the same over MCP with relm_create_webhook; the secret comes back the same way.
Recipe 0: a correct receiver (verify, then act)
Every delivery carries three headers: Relm-Event (the event type), Relm-Delivery (a stable id for idempotency), and Relm-Signature in the form t=<unix>,v1=<hex>. Verify before you trust the body - recompute HMAC-SHA256 of "<t>.<raw body>" with your secret over the raw bytes:
// Node (Express) - raw body is required for the signature to match
import express from "express";
import crypto from "node:crypto";
const app = express();
app.post("/relm/webhook", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
const [t, v1] = req.get("Relm-Signature").split(",").map(p => p.split("=")[1]);
const expected = crypto.createHmac("sha256", process.env.RELM_WEBHOOK_SECRET)
.update(`${t}.${raw}`).digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected)))
return res.status(400).send("bad signature");
const event = JSON.parse(raw); // { id, type, object, created_at, data }
// idempotency: ignore a delivery id you have already processed (retries can repeat)
if (!seen(req.get("Relm-Delivery"))) handle(event);
res.sendStatus(200); // 2xx fast; do slow work off the request
});
# Python (Flask)
import hmac, hashlib, os
from flask import Flask, request
app = Flask(__name__)
@app.post("/relm/webhook")
def hook():
raw = request.get_data() # bytes, unparsed
t, v1 = (p.split("=")[1] for p in request.headers["Relm-Signature"].split(","))
expected = hmac.new(os.environ["RELM_WEBHOOK_SECRET"].encode(),
f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(v1, expected):
return "bad signature", 400
handle(request.get_json())
return "", 200
Two rules that save you later: return a 2xx the moment you have persisted the event (Relm retries anything else), and make the handler idempotent on Relm-Delivery - a retried delivery repeats the same id, and at-least-once delivery means you will occasionally see one twice.
Recipe 1: post won deals to Slack
Subscribe to deal.stage_changed, check the stage in your handler, and post to an incoming Slack webhook. This is the canonical "sales just closed something" alert.
// inside handle(event):
if (event.type === "deal.stage_changed" && event.data.stage === "won") {
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ text: `:tada: Won: ${event.data.title} ($${(event.data.value_cents||0)/100})` }),
});
}
Recipe 2: keep billing and the CRM in sync (both directions)
The CRM should mirror commercial state without anyone hand-typing it. Outbound: on deal.stage_changed to won, create or update the customer in your billing system. Inbound: your billing system's own webhook (Stripe's customer.subscription.updated) patches the company back through Relm's REST API - the two directions together keep the SaaS data model fields like mrr and plan always current.
// outbound: Relm deal.won -> your billing
if (event.type === "deal.stage_changed" && event.data.stage === "won") {
await billing.createCustomer({ ref: event.data.id, name: event.data.title });
}
// inbound: your Stripe handler -> Relm (this is your code calling Relm's API)
await fetch(`https://api.relmcrm.com/v1/companies/${companyId}`, {
method: "PATCH",
headers: { authorization: `Bearer ${RELM_KEY}`, "content-type": "application/json" },
body: JSON.stringify({ custom_fields: { mrr: sub.amount, plan: sub.plan } }),
});
Recipe 3: stream every change into a warehouse
Subscribe to ["*"] and append each event to a queue or an append-only table. Because the payload is a full snapshot (event.data is the record after the change) and every delivery has a stable id, your warehouse gets an idempotent, replayable change feed with no polling and no missed rows.
curl https://api.relmcrm.com/v1/webhooks \
-H "Authorization: Bearer relm_live_..." -H "Content-Type: application/json" \
-d '{ "url": "https://ingest.your.app/relm", "events": ["*"], "description": "warehouse feed" }'
Operating them
- Retries & dead-letters. Non-2xx or timeout retries at ~1m, 5m, 30m, 2h, 6h, then the delivery is marked
dead. Inspect attempts atGET /v1/webhooks/{id}/deliveries. - Security. Live URLs must be public https; private, loopback and link-local hosts are rejected and re-checked by DNS at delivery. Always verify the signature - never trust an unsigned body.
- Rotation. Delete and re-create a webhook to roll its secret; update the endpoint's secret first, then cut over.
- Development. A
relm_test_key may point a webhook atlocalhostor an ngrok tunnel, so you can build the receiver before going live.
Apply it with your agent
Point any MCP client at Relm and it can stand up a subscription for you (full setup in how to give your AI agent a CRM):
Register a webhook to https://my.app/relm for deal.stage_changed,
then show me the last 5 deliveries and their status.
// -> relm_create_webhook, then GET /v1/webhooks/{id}/deliveries
Pair this with the stage-change playbooks (what should fire on each move) and the 10 automation recipes (the in-CRM half). See the webhooks section of the docs for the full reference.
FAQ
How do I verify a Relm webhook signature?
Every delivery carries a Relm-Signature header of the form t=<unix>,v1=<hex>. Recompute HMAC-SHA256 of "<t>.<raw request body>" using your webhook's signing secret and compare it to v1 with a constant-time check. Use the raw bytes of the body, not a re-serialized object, or the hash will not match.
What happens if my endpoint is down?
A non-2xx response or a timeout is retried with backoff - roughly 1m, 5m, 30m, 2h, 6h - then dead-lettered after 6 attempts. Inspect every attempt at GET /v1/webhooks/{id}/deliveries. Return a 2xx as soon as you have persisted the event and do slow work asynchronously.
Which events can I subscribe to?
contact.created, contact.updated, deal.created, deal.updated, deal.stage_changed - a subset or ["*"]. A deal created with a stage emits both deal.created and deal.stage_changed. Bulk imports via POST /v1/batch are event-silent.
Can a webhook point at an internal URL?
No. In live mode the URL must be public https; private, loopback and link-local addresses (including cloud metadata endpoints) are rejected at registration and re-checked by DNS at delivery. Test-mode keys may target localhost or ngrok.
Do I need Relm to use these patterns?
The patterns - signed delivery, idempotent handlers keyed on the delivery id, retry with backoff - are universal and apply to Stripe, GitHub or any producer. The header names and the register call are Relm's; the receiver is ordinary web-server code.
Wire your first webhook
A free key registers a test-mode webhook against localhost in one call.
Start free → Webhook docs