Webhooks
Last updated: August 2026
Catch CRM webhooks send a POST to your URL the moment something changes. There are 5 events: job.status_changed, lead.created, estimate.approved, payment.received, and review.received. Every request is signed with HMAC-SHA256, retried with backoff for up to 24 hours, and carries a unique event id so you can safely dedupe.
How do I set up a webhook?
In Settings, Webhooks, add your endpoint URL and pick the events to receive. Catch CRM shows a signing secret (it starts with whsec_). Store it as an environment variable; you will use it to verify every request.
What events can I subscribe to?
| Event | Fires when |
|---|---|
job.status_changed | A job moves between statuses (scheduled, in_progress, done, cancelled). |
lead.created | A new lead comes in from the booking widget, a form, or Zapier. |
estimate.approved | A customer approves and signs an estimate option. |
payment.received | A payment succeeds on an invoice. |
review.received | A new review lands, with a verified flag. |
What does a payload look like?
Every event shares the same envelope: an id, a type, a created timestamp, and a data object.
job.status_changed
{
"id": "evt_7f3a9c21",
"type": "job.status_changed",
"created": "2026-08-10T14:32:05Z",
"data": {
"job_id": "job_10482",
"title": "AC not cooling",
"from_status": "scheduled",
"to_status": "in_progress",
"technician": { "id": "usr_204", "name": "Mike R." },
"customer": { "id": "cst_8821", "name": "Dana Whitfield" },
"scheduled_for": "2026-08-10T15:00:00Z"
}
}lead.created
{
"id": "evt_2b81e4a0",
"type": "lead.created",
"created": "2026-08-10T14:05:11Z",
"data": {
"lead_id": "led_55210",
"name": "Priya Nadkarni",
"phone": "+15125550142",
"email": "priya@example.com",
"source": "booking_widget",
"service": "drain-cleaning",
"message": "Kitchen sink backing up."
}
}estimate.approved
{
"id": "evt_9d0c4471",
"type": "estimate.approved",
"created": "2026-08-10T16:20:44Z",
"data": {
"estimate_id": "est_3391",
"job_id": "job_10482",
"customer": { "id": "cst_8821", "name": "Dana Whitfield" },
"option": "Better",
"amount": 68900,
"currency": "usd",
"signed_at": "2026-08-10T16:20:44Z"
}
}Money is sent in the smallest currency unit, so 68900 means $689.00.
payment.received
{
"id": "evt_c17a5580",
"type": "payment.received",
"created": "2026-08-10T18:02:19Z",
"data": {
"payment_id": "pay_77120",
"job_id": "job_10482",
"invoice_id": "inv_4410",
"amount": 68900,
"currency": "usd",
"method": "card",
"processor": "stripe"
}
}review.received
{
"id": "evt_e5521903",
"type": "review.received",
"created": "2026-08-10T20:11:07Z",
"data": {
"review_id": "rev_2087",
"job_id": "job_10482",
"rating": 5,
"verified": true,
"text": "Fast, tidy, fixed it same day.",
"customer": { "id": "cst_8821", "name": "Dana Whitfield" },
"platform": "google"
}
}How do I verify the signature?
Each request carries a Catch-Signature header with a timestamp and a hex signature:
Catch-Signature: t=1754837525,v1=4b7d2f1a9c...The signed value is the timestamp, a dot, then the raw request body: `${t}.${rawBody}`. Recompute the HMAC-SHA256 with your signing secret and compare it to v1 using a constant-time check. Verify the raw body bytes, before any JSON parsing.
Node.js
const crypto = require("crypto");
// rawBody must be the exact bytes you received, before JSON.parse.
function verify(rawBody, signatureHeader, signingSecret) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((kv) => kv.split("="))
);
const signedPayload = `${parts.t}.${rawBody}`;
const expected = crypto
.createHmac("sha256", signingSecret)
.update(signedPayload)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 || "");
// Constant-time compare, and reject signatures older than 5 minutes.
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
return fresh && a.length === b.length && crypto.timingSafeEqual(a, b);
}PHP
<?php
// $rawBody must be the exact request body, e.g. file_get_contents('php://input').
function catch_verify(string $rawBody, string $signatureHeader, string $signingSecret): bool {
$parts = [];
foreach (explode(',', $signatureHeader) as $kv) {
[$k, $v] = array_pad(explode('=', $kv, 2), 2, '');
$parts[$k] = $v;
}
$signedPayload = ($parts['t'] ?? '') . '.' . $rawBody;
$expected = hash_hmac('sha256', $signedPayload, $signingSecret);
// Constant-time compare, and reject signatures older than 5 minutes.
$fresh = abs(time() - (int)($parts['t'] ?? 0)) < 300;
return $fresh && hash_equals($expected, $parts['v1'] ?? '');
}What is the retry and idempotency policy?
- Respond fast. Return a
2xxwithin 10 seconds. Do the slow work after you acknowledge. - Retries. Any non-2xx or timeout is retried with exponential backoff: roughly 1 minute, 5 minutes, 30 minutes, 2 hours, then hourly, for up to 24 hours.
- Dead-letter. After 24 hours of failures, the event moves to a dead-letter queue. Fix your endpoint and replay it from Settings, Webhooks.
- Idempotency. The event
id(for exampleevt_7f3a9c21) is stable across retries. Store handled ids and skip duplicates. - Ordering. Events can arrive out of order. Use the
createdtimestamp if order matters.
Prefer no code?
If you would rather not host an endpoint, use Zapier to turn the same events into actions in 6,000+ apps.
