Skip to content

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.

EventFires when
account.createdA new account lands in the project (manual, import, lookup, inbound)
account.status_changedAn account actually moves in the pipeline — prospect → contacted, → customer, → do_not_contact, …
message.inboundAn 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.

  1. Open Settings → Webhooks (per project).
  2. Add the destination URL (must be https:// on a public address — private, local-network and internal addresses are refused), pick the events, save.
  3. Copy the secret immediately — it’s shown once, at creation. If you lose it, rotate it (rotation invalidates the old one).
  4. Hit Test — LeadHunter sends a synthetic webhook.test event through the real delivery pipeline so you can verify your receiver end-to-end before real traffic flows.

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 id is 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.

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_id belongs 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_id field. 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) and is_verified (true / false).
  • Per-purpose opt-outs: do_not_contact_purposes is 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 under dnc_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.

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.

Richer two-way sync (bulk upserts, field-level sync rules) builds on the upsert endpoint above; webhooks + upsert already close the basic loop.