← Resources

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

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