Connect Housecall Pro
Goal: every lead you accept lands in Housecall Pro's Job inbox, and when you book, finish or lose the job in Housecall Pro, the lead updates here.
Easiest: My account, Integrations, Your software, Housecall Pro. Paste your Housecall Pro API key once and every lead you accept goes into Housecall Pro within a minute. Use Zapier or the relay below when you also want Housecall Pro job status sent back here.
What you need
- Housecall Pro MAX plan. Both the API and webhooks are MAX-only.
- In Housecall Pro: My Apps, Job inbox, turn on API Leads. Leads created through the API appear in their own "API Leads" channel, where your office can reply and convert them.
- In Housecall Pro: an admin generates an API key (App Store, Housecall Pro API). Treat it like a password.
- Here: an API key with Let it make changes on, and a webhook address with its secret.
Housecall Pro's API has no "update lead" call, so updates flow one way each: our leads go in as new Housecall Pro leads; Housecall Pro's webhooks tell us what happened. Housecall Pro also has no API for its Inbox, so customer messages stay in our app (or your own system, through our messages API).
Field mapping (into Housecall Pro)
Housecall Pro POST /leads field | From our lead |
|---|---|
customer.first_name | contact.first_name |
customer.last_name | contact.last_name |
customer.mobile_number | contact.phone |
customer.email | contact.email |
customer_id (instead of customer) | existing Housecall Pro customer with the same phone, found with GET /customers?q=<phone> |
lead_source | your marketplace name |
note | service, zip, details and the lead id |
tags | for example ["marketplace", "<service_id>"] |
These field names are the ones used by a working Housecall Pro integration on a MAX account (October 2026). Confirm them against Housecall Pro's current docs at docs.housecallpro.com before going live.
Status mapping (back to us)
Event names below are the ones Housecall Pro lists in its webhook settings as of October 2026; check the list on your account when you subscribe.
| Housecall Pro webhook event | Send us |
|---|---|
lead.converted, estimate.created, estimate.scheduled | appointment |
job.created, job.scheduled | hired |
job.completed, job.paid | job_done |
lead.lost | not_hired |
Option 1: Zapier (no code)
- Zap A, leads in: trigger Webhooks by Zapier, Catch Hook. Paste the hook address into our Settings as your webhook. Add a Filter: continue only if
eventislead.accepted. Action: Housecall Pro (or Webhooks by Zapier, Custom RequestPOST https://api.housecallpro.com/leadswith headerAuthorization: Bearer <Housecall Pro key>), mapping the fields above. - Zap B, status back: trigger Webhooks by Zapier, Catch Hook, and paste that address into Housecall Pro (My Apps, Webhooks), choosing the lead, estimate and job events above. Add a Paths step for the mapping. Action: Webhooks by Zapier, Custom Request, method
PATCH, URLhttps://zqawbbylldymdfzineqq.supabase.co/functions/v1/pro-api/v1/leads/<our lead id>, headersAuthorization: Bearer <our key>andContent-Type: application/json, body{"status":"hired"}. - To know "our lead id" in Zap B, store it when Zap A runs (Zapier Storage keyed by the Housecall Pro customer id or phone), or put it in the Housecall Pro note and read it back.
Option 2: a small relay (Cloudflare Worker)
One file, free tier is enough, no database: it saves the Housecall Pro ids on our lead (external_ids) and finds the lead again by them. Secrets: OUR_KEY, OUR_WEBHOOK_SECRET, HCP_KEY, HCP_WEBHOOK_SECRET.
const OURS = 'https://zqawbbylldymdfzineqq.supabase.co/functions/v1/pro-api/v1/leads';
const HCP = 'https://api.housecallpro.com';
const enc = new TextEncoder();
async function hmacHex(secret, msg) {
const k = await crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
return [...new Uint8Array(await crypto.subtle.sign('HMAC', k, enc.encode(msg)))].map((b) => b.toString(16).padStart(2, '0')).join('');
}
const ours = (env, idem) => ({ Authorization: `Bearer ${env.OUR_KEY}`, 'Content-Type': 'application/json', ...(idem ? { 'Idempotency-Key': idem } : {}) });
const fresh = (t) => Math.abs(Date.now() / 1000 - Number(t)) < 300;
async function ourSigOk(raw, header, secret) {
const p = Object.fromEntries(String(header || '').split(',').map((x) => x.split('=', 2)));
return !!p.t && fresh(p.t) && p.v1 === (await hmacHex(secret, `${p.t}.${raw}`));
}
async function hcpSigOk(raw, ts, sig, secret) { // Housecall Pro: HMAC-SHA256 of "<Api-Timestamp>.<body>"
if (!ts || !sig || !fresh(ts)) return false;
return String(sig).replace(/^sha256=/i, '') === (await hmacHex(secret, `${ts}.${raw}`));
}
const MAP = { 'lead.converted': 'appointment', 'estimate.created': 'appointment', 'estimate.scheduled': 'appointment',
'job.created': 'hired', 'job.scheduled': 'hired', 'job.completed': 'job_done', 'job.paid': 'job_done', 'lead.lost': 'not_hired' };
export default {
async fetch(req, env, ctx) {
const url = new URL(req.url), raw = await req.text();
if (req.method !== 'POST') return new Response('ok');
if (url.pathname === '/from-marketplace') {
if (!(await ourSigOk(raw, req.headers.get('X-Signature'), env.OUR_WEBHOOK_SECRET))) return new Response('bad signature', { status: 401 });
const ev = JSON.parse(raw), lead = ev.lead;
if (ev.event !== 'lead.accepted' || !lead?.contact || lead.external_ids?.housecallpro) return new Response('ok');
ctx.waitUntil((async () => {
const h = { Authorization: `Bearer ${env.HCP_KEY}`, Accept: 'application/json', 'Content-Type': 'application/json' };
let customerId = null;
if (lead.contact.phone) {
const q = await fetch(`${HCP}/customers?q=${encodeURIComponent(lead.contact.phone.replace(/^\+1/, ''))}&page_size=10`, { headers: h });
const list = (await q.json()).customers || [];
customerId = list[0]?.id || null;
}
const body = customerId ? { customer_id: customerId } : { customer: {
first_name: lead.contact.first_name, last_name: lead.contact.last_name || 'Customer',
mobile_number: lead.contact.phone || undefined, email: lead.contact.email || undefined } };
body.lead_source = 'Marketplace';
body.note = `${lead.service} in ${lead.zip}\n${lead.details}\nMarketplace lead ${lead.id}`;
body.tags = ['marketplace'];
const r = await fetch(`${HCP}/leads`, { method: 'POST', headers: h, body: JSON.stringify(body) });
if (!r.ok) throw new Error('Housecall Pro ' + r.status);
const made = await r.json();
const cust = made.customer?.id || customerId;
await fetch(`${OURS}/${lead.id}`, { method: 'PATCH', headers: ours(env, `link-${lead.id}`),
body: JSON.stringify({ external_ids: { housecallpro: String(made.id), housecallpro_customer: cust ? String(cust) : null } }) });
})());
return new Response('ok');
}
if (url.pathname === '/from-hcp') {
if (!(await hcpSigOk(raw, req.headers.get('Api-Timestamp'), req.headers.get('Api-Signature'), env.HCP_WEBHOOK_SECRET))) return new Response('bad signature', { status: 401 });
const ev = JSON.parse(raw), status = MAP[ev.event];
if (!status) return new Response('ok');
const obj = ev.lead || ev.job || ev.estimate || {};
ctx.waitUntil((async () => {
const find = async (q) => (await (await fetch(`${OURS}?external_id=${encodeURIComponent(q)}`, { headers: ours(env) })).json()).leads?.[0];
const lead = (obj.id && (await find('housecallpro:' + obj.id))) || (obj.customer?.id && (await find('housecallpro_customer:' + obj.customer.id)));
if (!lead) return;
await fetch(`${OURS}/${lead.id}`, { method: 'PATCH', headers: ours(env, `${ev.event}-${obj.id}`), body: JSON.stringify({ status, reason: ev.event }) });
})());
return new Response('ok');
}
return new Response('not found', { status: 404 });
},
};Then:
- Put
https://<your-worker>/from-marketplacein our Settings as your webhook address. - In Housecall Pro, My Apps, Webhooks: add
https://<your-worker>/from-hcp, copy its signing secret intoHCP_WEBHOOK_SECRET, and pick the events in the status table. - Accept a test lead and check the Housecall Pro "API Leads" channel. Book it in Housecall Pro and check the lead's status here.
Log one of each Housecall Pro event before relying on the mapping: event payload shapes (for example where the customer id sits) can differ by account and change over time. A 409 from us when sending a status usually means the move is not allowed from the lead's current status; it is safe to ignore.
Turn off Housecall Pro's duplicate sources
If you also get these customers through another Housecall Pro integration, make sure each lead arrives once, by checking for an existing customer by phone first (the relay does this).