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?

EventFires when
job.status_changedA job moves between statuses (scheduled, in_progress, done, cancelled).
lead.createdA new lead comes in from the booking widget, a form, or Zapier.
estimate.approvedA customer approves and signs an estimate option.
payment.receivedA payment succeeds on an invoice.
review.receivedA 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 2xx within 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 example evt_7f3a9c21) is stable across retries. Store handled ids and skip duplicates.
  • Ordering. Events can arrive out of order. Use the created timestamp 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.

Webhooks: common questions

How do I verify a Catch CRM webhook is real?

Recompute the HMAC-SHA256 of the timestamp plus a dot plus the raw body, using your signing secret, and compare it to the v1 value in the Catch-Signature header with a constant-time check.

What happens if my endpoint is down?

Catch CRM retries on any non-2xx response or timeout, backing off from about 1 minute up to 24 hours. After 24 hours the event moves to a dead-letter queue you can replay.

How do I avoid processing the same event twice?

Every event has a unique id like evt_7f3a9c21 that stays the same across retries. Store processed ids and skip any id you have already handled.

How many webhook events are there?

There are 5: job.status_changed, lead.created, estimate.approved, payment.received, and review.received. Subscribe to any or all in Settings, Webhooks.