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 fieldFrom our lead
customer.first_namecontact.first_name
customer.last_namecontact.last_name
customer.mobile_numbercontact.phone
customer.emailcontact.email
customer_id (instead of customer)existing Housecall Pro customer with the same phone, found with GET /customers?q=<phone>
lead_sourceyour marketplace name
noteservice, zip, details and the lead id
tagsfor 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 eventSend us
lead.converted, estimate.created, estimate.scheduledappointment
job.created, job.scheduledhired
job.completed, job.paidjob_done
lead.lostnot_hired

Option 1: Zapier (no code)

  1. 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 event is lead.accepted. Action: Housecall Pro (or Webhooks by Zapier, Custom Request POST https://api.housecallpro.com/leads with header Authorization: Bearer <Housecall Pro key>), mapping the fields above.
  2. 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, URL https://zqawbbylldymdfzineqq.supabase.co/functions/v1/pro-api/v1/leads/<our lead id>, headers Authorization: Bearer <our key> and Content-Type: application/json, body {"status":"hired"}.
  3. 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:

  1. Put https://<your-worker>/from-marketplace in our Settings as your webhook address.
  2. In Housecall Pro, My Apps, Webhooks: add https://<your-worker>/from-hcp, copy its signing secret into HCP_WEBHOOK_SECRET, and pick the events in the status table.
  3. 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).