Outbound webhooks & API keys
Push CRM events to your own systems with signed webhooks, and let external apps read data through scoped API keys.
Outbound webhooks & API keys
Two ways to connect the CRM to other systems:
- Outbound webhooks — the CRM calls your URL whenever something happens (a lead is created, a stage changes…) with a signed JSON payload.
- API keys — your systems call the CRM's REST API with a scoped key.
Both live in the Advanced group of Settings and are gated by your plan (features webhooks and api).
Who this is for
- Admin who subscribes to CRM events.
- Developer integrating an ERP, ad platform, call tool or data warehouse.
- Super Admin auditing what a workspace has connected.
Difficulty: Advanced · Time: about 15 minutes · Prerequisites:
- A public HTTPS endpoint on your side that can verify a signature.
- The plan feature enabled for webhooks / API access.
Part 1 — Outbound webhooks
Step 1 — Create the webhook
Open Settings → Advanced → Outbound Webhooks → New, and fill:
| Field | Notes |
|---|---|
| Name | Label for your own reference |
| URL | Your HTTPS endpoint. Private/loopback/link-local and cloud-metadata addresses are blocked by the SSRF guard — use a public host |
| Events | Any of: lead.created, lead.updated, lead.deleted, lead.stage_changed, form.submitted, automation.triggered |
| Secret | Auto-filled with a random value — copy it into your receiver to verify signatures |
| Filters (optional) | Narrow events: source, status, pipeline_id, pipeline_stage_id, assigned_user_id, tags. Filters combine with AND across fields and OR within a field; tags matches on intersection |
| Enabled | Off means nothing is delivered |
The secret is write-only later The secret is generated for you and stored encrypted; it is never included in API responses or listings. Copy it to your receiver when you create the webhook — you cannot read it back from the page afterwards.
Step 2 — Test and watch deliveries
- Send test fires a sample delivery to your URL and reports the result.
- View deliveries opens the delivery log: status (pending / success / failed), response code, response body (first ~4,000 characters), latency and attempt count.
Step 3 — Verify the signature in your receiver
Delivery attempts carry these headers:
| Header | Content |
|---|---|
X-<Brand>-Event |
The event name, e.g. lead.created |
X-<Brand>-Signature |
t=<unix>,v1=<hex hmac sha256> |
X-<Brand>-Timestamp |
Unix timestamp |
X-<Brand>-Webhook |
The delivery id |
User-Agent |
<Brand>-Webhooks/1.0 |
<Brand> is the platform brand slug (on the default install: X-LeadHub-Signature). The v1 value is HMAC-SHA256(secret, "{t}.{body}") — Stripe-style. Verify like this (PHP):
$header = $_SERVER['HTTP_X_LEADHUB_SIGNATURE'] ?? '';
$parts = [];
foreach (explode(',', $header) as $kv) {
[$k, $v] = explode('=', $kv, 2) + [null, null];
$parts[$k] = $v;
}
$t = (int) ($parts['t'] ?? 0);
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
$fresh = abs(time() - $t) <= 300; // 5-minute tolerance
if (!$fresh || !hash_equals($expected, $parts['v1'] ?? '')) {
http_response_code(403);
exit;
}
Retries and queueing Deliveries run on a dedicated webhooks queue with up to 5 attempts (30-second timeout); a mid-flight failure reuses the same delivery row so the log stays accurate. A URL blocked by the safety guard is not retried — fix the URL and re-enable.
The payload
{
"event": "lead.created",
"tenant_id": 42,
"timestamp": "2026-10-05T09:30:00Z",
"data": { "...event-specific fields..." }
}
Respond with any 2xx quickly and process asynchronously. Treat deliveries as at-least-once: keep handlers idempotent, keyed on the payload's entity id plus event.
Part 2 — API keys
Open Settings → Advanced → API Keys → New:
| Field | Notes |
|---|---|
| Name | What the key is for (e.g. "Warehouse sync") |
| Scopes | Choose only what the integration needs |
| Rate limit | Requests per hour — default 1,000, maximum 100,000 |
| Expires at | Optional expiry date; leave blank for a non-expiring key |
The key value is shown once at creation; afterwards the table shows only a masked prefix.
Available scopes:
read:leads write:leads delete:leads
read:pipelines write:pipelines
read:tags write:tags
read:users write:users delete:users
read:forms write:forms delete:forms
read:automations write:automations delete:automations
read:integrations write:integrations
Use the key as a bearer token against the REST API (/api/v1/...):
curl -H "Authorization: Bearer YOUR_API_KEY" \
https://your-crm-domain/api/v1/leads
The interactive reference is at /api/docs (OpenAPI viewer), and a health probe is available at /api/v1/health. Full details in Authentication and REST API.
Rate limits are per key Exceeding the hourly limit returns 429 with retry information. Give each integration its own key so one noisy consumer cannot exhaust another's quota — and so you can revoke one without breaking the rest.
Expected result
A test delivery arrives at your endpoint and verifies against the secret; live events flow into your delivery log with success statuses. Your integration can read/write CRM data with its scoped key within its rate limit.
Common mistakes
- Verifying the signature over the wrong string — it is
"{t}.{body}", not just the body, and the timestamp comes from the header, not the payload. - Skipping the freshness check — always reject old timestamps to prevent replay.
- Using the secret from the API — it is not exposed there by design; save it at creation.
- A URL behind basic auth or on a private network — blocked by the SSRF guard; nothing is delivered and no retry happens.
- Over-scoped API keys — grant
read:leadswhen read is all you need. - Busy handler — acknowledging after slow work causes retries; acknowledge first.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Test delivery fails instantly | URL is private/blocked or not HTTPS | Use a public HTTPS endpoint |
Deliveries show failed with 4xx |
Your endpoint rejected the payload (or signature check failed) | Inspect the stored response body in the delivery log |
| No deliveries at all | Webhook disabled, wrong events chosen, or filters exclude everything | Check the enabled toggle, event list and filters |
| Duplicate deliveries | At-least-once delivery after a timeout | Make handlers idempotent |
API key returns 401 |
Key revoked, expired, or wrong header format | Create a new key / fix the Bearer header |
API key returns 429 |
Hourly limit reached | Raise rate_limit_per_hour or slow the client |
Technical details
- Delivery job:
DispatchOutboundWebhookon thewebhooksqueue ($tries = 5, 30s timeout); records live in the delivery log with statusespending/success/failed(WebhookDelivery::STATUS_*). - URL safety:
UrlSafetyGuardblocks private, loopback, link-local and metadata IP ranges (SSRF protection). Blocked URLs are not retried. - Signature headers are branded via
WebhookBranding(tenant → global → config →LeadHub). - Secret storage: encrypted cast,
$hiddenon the model, never serialized into API listings. - API keys:
ApiKeyResource; scopes listed above; quota ceiling governed by the plan'smax_api_keys; keys are hashed at rest and matched by prefix + hash. - Queue note: production needs a worker covering the
webhooksqueue — see Cron & queue workers.
Related guides
Was this page helpful?
Thanks — noted. ✦