Webhooks

We send an HTTPS POST to your address for every new lead and every change, usually within a minute: leads, messages, charges, credits, reviews and business settings.

Set up

  1. My account, Settings, Connect your software, Your webhook address. Paste an address that starts with https:// and reaches the public internet (private and local addresses are refused).
  2. Copy the signing secret (starts with whsec_). It is shown once. Saving a new address makes a new secret.
  3. Tap Send a test event and confirm your endpoint got webhook.test.
  4. Use the Send updates switch to pause and resume delivery. The card shows the last successful delivery or the last error.

Events

EventWhenExtra object
lead.receivedA lead is sent to you.
lead.acceptedYou accept a lead (contact details now included).
lead.declinedYou decline a lead (contact is null).
lead.statusThe status changes, from the app or from software.
lead.follow_upA follow-up date is set or changed.
message.receivedThe customer sent you a message.message
message.sentA message was sent to the customer (from the app or the API).message
charge.createdA lead charge is recorded.charge
charge.voidedA charge is voided (for example an approved credit).charge
charge.paidA charge is marked paid.charge
credit.requestedA credit was asked for.
credit.approved, credit.deniedWe decided the credit.
review.createdA customer rated you.review
review.updatedA review comment was approved for display.review
business.paused, business.resumedNew leads were paused or resumed.business
webhook.testYou tapped Send a test event. lead is null.

Filter on event (or type, the same value) and ignore events you do not use. We add events within v1; your code should skip names it does not know.

What we send

Headers:

HeaderExample
Content-Typeapplication/json
X-Eventlead.status
X-Event-Idevt_1042
X-Signaturet=1792713600,v1=5f2b...
Api-Timestamp1792713600 (same t, for code written for Housecall Pro)
Api-Signature5f2b... (same v1)
User-Agentlead-marketplace-webhooks/1

Body:

{
  "id": "evt_1042",
  "event": "message.received",
  "type": "message.received",
  "api_version": "2026-10-23",
  "business_id": "0d3b7c4e-...",
  "sequence": 1042,
  "created_at": "2026-10-22T16:20:45.002+00:00",
  "sent_at": "2026-10-22T16:21:00.118+00:00",
  "lead": { "...": "the full lead object, same as GET /v1/leads/{id}" },
  "message": {"id":"msg_903","lead_id":"6b1c...","direction":"in","body":"Tuesday works","created_at":"2026-10-22T16:20:45Z","read_at":null}
}

lead and the extra objects are the state at the moment we send, not at the moment of the change. sequence only goes up; use it, or lead.updated_at, to ignore an older delivery that arrives late.

Check the signature

X-Signature holds a Unix time t and v1, the hex HMAC-SHA256 of t + "." + raw body using your signing secret. Always:

  • Use the raw request body exactly as received. Do not parse and re-serialize JSON first (our JSON spacing will differ from yours).
  • Reject if t is more than 5 minutes from your clock.
  • Compare with a constant-time function.

Node.js (Express)

import crypto from 'node:crypto';
import express from 'express';

function verify(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(String(header || '').split(',').map((p) => p.split('=', 2)));
  const t = parts.t, v1 = parts.v1;
  if (!t || !v1 || Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  return v1.length === expected.length && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}

const app = express();
app.post('/leads-hook', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body.toString('utf8');
  if (!verify(raw, req.get('X-Signature'), process.env.WEBHOOK_SECRET)) return res.sendStatus(401);
  res.sendStatus(200);                 // answer first
  queueForProcessing(JSON.parse(raw)); // then do the work
});

Python (Flask)

import hmac, hashlib, time, os
from flask import Flask, request

def verify(raw: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split('=', 1) for p in (header or '').split(',') if '=' in p)
    t, v1 = parts.get('t'), parts.get('v1')
    if not t or not v1 or abs(time.time() - int(t)) > tolerance:
        return False
    expected = hmac.new(secret.encode(), t.encode() + b'.' + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)

app = Flask(__name__)

@app.post('/leads-hook')
def leads_hook():
    raw = request.get_data()
    if not verify(raw, request.headers.get('X-Signature', ''), os.environ['WEBHOOK_SECRET']):
        return '', 401
    queue_for_processing(raw)
    return '', 200

PHP

<?php
function verify_signature(string $raw, string $header, string $secret, int $tolerance = 300): bool {
    $parts = [];
    foreach (explode(',', $header) as $p) {
        [$k, $v] = array_pad(explode('=', $p, 2), 2, '');
        $parts[$k] = $v;
    }
    if (empty($parts['t']) || empty($parts['v1']) || abs(time() - (int)$parts['t']) > $tolerance) return false;
    $expected = hash_hmac('sha256', $parts['t'] . '.' . $raw, $secret);
    return hash_equals($expected, $parts['v1']);
}

$raw = file_get_contents('php://input');
if (!verify_signature($raw, $_SERVER['HTTP_X_SIGNATURE'] ?? '', getenv('WEBHOOK_SECRET'))) {
    http_response_code(401); exit;
}
http_response_code(200);
// process $raw after answering

These three samples are tested against our signing code.

Delivery rules

  • Answer with any 2xx within 5 seconds. Do the slow work after answering.
  • No answer, a timeout or any other status is a failure. We retry up to 24 tries over about 3 days, waiting 2, 4, 8, 16, 32, 64 and 128 minutes, then every 4 hours.
  • If every try for an event fails for about 3 days, your webhook turns off, Settings shows why, and we email you. Fix it, turn Send updates back on, and replay the missed time.
  • 410 Gone turns your webhook off right away (useful when you delete a Zapier or Make hook).
  • Delivery is at least once: the same event can arrive twice, with the same id. Skip ids you already handled.
  • Every try is in the delivery log; you can redeliver one event or replay a time range.
  • Webhooks are a speed-up, not the record. Run a poll every 5 to 15 minutes as a backup.