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
- 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). - Copy the signing secret (starts with
whsec_). It is shown once. Saving a new address makes a new secret. - Tap Send a test event and confirm your endpoint got
webhook.test. - Use the Send updates switch to pause and resume delivery. The card shows the last successful delivery or the last error.
Events
| Event | When | Extra object |
|---|---|---|
lead.received | A lead is sent to you. | |
lead.accepted | You accept a lead (contact details now included). | |
lead.declined | You decline a lead (contact is null). | |
lead.status | The status changes, from the app or from software. | |
lead.follow_up | A follow-up date is set or changed. | |
message.received | The customer sent you a message. | message |
message.sent | A message was sent to the customer (from the app or the API). | message |
charge.created | A lead charge is recorded. | charge |
charge.voided | A charge is voided (for example an approved credit). | charge |
charge.paid | A charge is marked paid. | charge |
credit.requested | A credit was asked for. | |
credit.approved, credit.denied | We decided the credit. | |
review.created | A customer rated you. | review |
review.updated | A review comment was approved for display. | review |
business.paused, business.resumed | New leads were paused or resumed. | business |
webhook.test | You 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:
| Header | Example |
|---|---|
Content-Type | application/json |
X-Event | lead.status |
X-Event-Id | evt_1042 |
X-Signature | t=1792713600,v1=5f2b... |
Api-Timestamp | 1792713600 (same t, for code written for Housecall Pro) |
Api-Signature | 5f2b... (same v1) |
User-Agent | lead-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
tis 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 '', 200PHP
<?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 answeringThese three samples are tested against our signing code.
Delivery rules
- Answer with any
2xxwithin 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 Goneturns 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.