Push events to your tools — webhooks
Webhooks push LeadHunter events to any system that can receive an HTTPS POST — your CRM, Zapier, n8n, Make, a Slack relay, your own backend. Deep-link custom fields let you jump into your other tools; webhooks are the other direction: LeadHunter telling your tools what just happened.
What gets pushed
Section titled “What gets pushed”| Event | Fires when |
|---|---|
account.created | A new account lands in the project (manual, import, lookup, inbound) |
account.status_changed | An account actually moves in the pipeline — prospect → contacted, → customer, → do_not_contact, … |
message.inbound | An inbound reply is logged on a conversation |
Every payload carries the account’s core fields including
imported_id — so if the account came from your CRM, the event
arrives already labelled with your record id and matching it on the
receiving side is a lookup, not a fuzzy search. Custom fields ride
along too.
Set one up
Section titled “Set one up”- Open Settings → Webhooks (per project).
- Add the destination URL (must be
https://on a public address — private, local-network and internal addresses are refused), pick the events, save. - Copy the secret immediately — it’s shown once, at creation. If you lose it, rotate it (rotation invalidates the old one).
- Hit Test — LeadHunter sends a synthetic
webhook.testevent through the real delivery pipeline so you can verify your receiver end-to-end before real traffic flows.
Verifying the signature
Section titled “Verifying the signature”Every delivery is signed so your receiver can prove it came from
LeadHunter and wasn’t tampered with. The X-LeadHunter-Signature
header carries sha256=<hex> — the HMAC-SHA256 of the raw request
body with your webhook’s secret as the key. Recompute it on your
side and compare:
import hashlib, hmac
expected = "sha256=" + hmac.new( secret.encode(), raw_body, hashlib.sha256).hexdigest()ok = hmac.compare_digest(expected, request.headers["X-LeadHunter-Signature"])Two more headers help with bookkeeping: X-LeadHunter-Event (the
event type) and X-LeadHunter-Delivery (the delivery id).
Delivery semantics — what your receiver should expect
Section titled “Delivery semantics — what your receiver should expect”- At-least-once. Failures are retried with backoff (a 500, a
timeout, a 429). The payload’s top-level
idis stable across retries and redeliveries of the same event — dedupe on it if your handler isn’t naturally idempotent. - Respond fast with a 2xx. Anything else counts as a failure. Do the heavy work async on your side.
- Permanent rejections aren’t retried. A 404/401/410 marks the delivery failed immediately — retrying wouldn’t heal it.
- Dead endpoints switch themselves off. After 20 consecutive failures the webhook is auto-disabled (you’ll see “auto-disabled after repeated failures” on the settings page). Fix the receiver, hit Test, then re-enable — the failure streak resets.
Replaying
Section titled “Replaying”Each webhook’s Recent deliveries panel shows every attempt with its status, response code, and error. Any delivery can be redelivered with one click — same payload, fresh attempt — which is the tool for “our endpoint was down for an hour” recoveries. Delivery history is kept for 90 days; anything older is pruned automatically.
Pushing data back in — upsert by imported id
Section titled “Pushing data back in — upsert by imported id”Webhooks are LeadHunter → your system. The return path is one endpoint:
POST /api/accounts/upsert-by-imported-id/{ "project": "my-project", "imported_id": "crm-4711", "status": "customer", "custom_fields": {"plan": "pro"} }LeadHunter finds the account whose imported_id matches (the same
correlation key the webhook payloads carry) and patches only the
fields you send — custom_fields merge per key, and a null
deletes a key. No match → the account is created. Status changes go
through the normal pipeline, so the audit trail and your other
webhooks fire exactly as if an operator made the change — and pushing
the same status twice is a no-op, so periodic full-state re-syncs
don’t pollute the history. Authenticate with your API token like any
other endpoint.
The response is {created, account}: 201 when the account was
created, 200 when an existing one was updated.
A few rules worth knowing when you build a sync on it:
- An
imported_idbelongs to one account per project. LeadHunter won’t keep two accounts with the same id in a project, so the id always resolves to exactly one account. - Retries and parallel pushes are safe. If two pushes for a new id
arrive at the same moment, one creates the account and the other is
applied to it as an update (200,
created: false). You never end up with two accounts. - A Google Place ID already used by another account of the project is
refused with a 400 that names the
google_place_idfield. Merge the two accounts, or send the push without that field. - Classification fields: besides the contact and company fields,
you can send
relationship_types(a list of the known relationship types, duplicates dropped, at most 8) andis_verified(true / false). - Per-purpose opt-outs:
do_not_contact_purposesis the full list of campaign goals the account has opted out of (["sales"],[]to clear them). LeadHunter compares it with the account and records one opt-out or opt-in per change in the do-not-contact history; re-sending the same list changes nothing. An opt-out the person made themselves (they wrote in or unsubscribed) is never reversed by a push: it stays, and the response lists it underdnc_kept. Reverse it deliberately with the account’s do-not-contact action.
Together that’s the full loop: a webhook tells your CRM an account
replied; your CRM closes the deal and pushes status: customer back.
Promo-code attribution in the same call
Section titled “Promo-code attribution in the same call”If your channels carry promo codes, add the code the customer used and the conversion attributes itself:
POST /api/accounts/upsert-by-imported-id/{ "project": "my-project", "imported_id": "shop-1042", "name": "New Customer SL", "promo_code": "laia20", "status": "customer", "captured_value": "890.00", "captured_value_currency": "EUR" }The code matches case-insensitively (codes are letters, digits,
hyphens and underscores, up to 64 characters, unique per project) and
the account lands on that code’s channel with its acquisition date
stamped. An unknown code never fails the push — the response includes
promo_code_matched: true|false so your flow can log misses. One
call: attributed, valued, closed.
What’s next
Section titled “What’s next”Richer two-way sync (bulk upserts, field-level sync rules) builds on the upsert endpoint above; webhooks + upsert already close the basic loop.
Read next
Section titled “Read next”- Custom fields → Deep links into external systems — the inbound-click half of integrations.
- Import accounts — where
imported_idcomes from.