HDI CRM Integration: Lead Webhook and Leads API
How do leads reach my CRM?
HDI sends each lead to the Webhook URL you set, as a signed request. You can also pull your leads at any time with the Leads API. Both use the same lead object, with the same id, so a lead that arrives twice is easy to spot. There are three ways to connect:
- Your own endpoint. HDI posts to a URL on your server. You check the signature, save the lead and answer 200.
- Zapier or Make. A hook in either tool receives the lead and creates a record in HubSpot, Salesforce or Pipedrive.
- The Leads API. Your system asks HDI for new leads on a schedule. Use it alone, or to catch up after your endpoint was down.
How do I set up the webhook?
Open your partner page, enter your partner key and follow these steps:
- Enter the URL that should receive leads in "Webhook URL" and save it.
- Press "Send test lead". HDI sends a sample lead marked
"test": trueto your URL and shows the HTTP status your endpoint answered. Status 0 means HDI could not connect. - Press "Show signing secret" and store the secret on your server, for example in an environment variable. Keep it out of web pages and repositories.
- Check the signature of every request, as shown below.
The Webhook URL must use HTTPS and a public domain name, on the default port, with no username or password in it and at most 500 characters. HDI refuses IP addresses, localhost, single-word hosts and internal names such as .local, .internal or .lan. Addresses on hdi-audit.com and workers.dev are refused too, so use your own domain. HDI checks the address again before every send.
How do I connect HubSpot, Salesforce or Pipedrive?
Go through Zapier or Make. In Zapier, start a Zap with Webhooks by Zapier and the Catch Hook event. In Make, start a scenario with the Webhooks module and its Custom webhook trigger. Copy the hook URL into "Webhook URL" on your partner page and press "Send test lead", so the tool learns the fields. Then add a step that creates a contact or lead in HubSpot, Salesforce or Pipedrive and map the fields from the table below.
Zapier and Make accept the request without checking the signature, so the hook URL is the only secret. Keep it private. If you need the signature check, receive the lead on your own endpoint.
What does a lead look like?
HDI sends a JSON body with the event, a few details of the delivery and the lead itself. This lead comes from a fictional company:
{
"event": "hdi.lead.screening_completed",
"sent_at": "2026-10-05T09:14:03.512Z",
"partner": "acme-automation",
"test": false,
"lead": {
"id": "ld_552aba8660d7b1748d214a5f",
"submitted_at": "2026-10-05T09:14:02.000Z",
"company": "Example Bio Ltd",
"email": "jane.doe@example.com",
"industry": "cgt",
"industry_label": "cell & gene therapy",
"hdi_score": 58,
"band": "High Risk",
"layers": { "coordination": 57, "control": 56, "cognitive": 54 },
"operational_leak_hours_month": 1290,
"results_url": "https://hdi-audit.com/results.html?email=jane.doe%40example.com&sig=-px3I8er4v-yO1W0z88RuWxB2bRi4nfxa-JugMph6nI"
}
}
The request is a POST with these headers:
Content-Type: application/json
User-Agent: HDI-Webhooks/1
X-HDI-Timestamp: <unix time in seconds>
X-HDI-Signature: sha256=<64 hex characters>
| Field | Meaning |
|---|---|
event | Always hdi.lead.screening_completed. |
sent_at | When HDI sent the request, UTC, ISO 8601. |
partner | Your partner slug, the last part of your screening link. |
test | true for the sample sent by "Send test lead", false for a real lead. |
lead.id | A stable id: ld_ and 24 hex characters. The same submission has the same id in the webhook and in the Leads API. |
lead.submitted_at | When the client finished the screening, UTC, ISO 8601, to the whole second (.000Z). |
lead.company | The company name the client entered. Optional: not every lead has one. |
lead.email | The client's work email. |
lead.industry | The sector code: cgt, atmp, cdmo, pharma, clinical or medlog. |
lead.industry_label | The sector as text: cell & gene therapy, ATMP manufacturing, CDMOs, conventional pharma, clinical research or medical logistics. |
lead.hdi_score | The HDI score, 0 to 100. Lower is better. null if the stored answers cannot be read. |
lead.band | Systemised, Managed, Moderate, High Risk or Critical. |
lead.layers | The three layer scores, 0 to 100: coordination, control and cognitive. Each can be null. |
lead.operational_leak_hours_month | The hours a month the client's team spends compensating for dependency. |
lead.results_url | Optional. A link for you that opens the client's free result. It works only while the client is on your account: if the client later moves to another partner or screens without a partner link, it stops working. Anyone with the link can open the result, so treat it like a password. |
The screening does not collect a contact name, a role or a country, so the lead has none of them.
How do I check the signature?
Rebuild the signature from the raw body and compare it with the header. The signature is sha256= followed by the hex digest of an HMAC-SHA256. The key is your signing secret as plain text (UTF-8). The signed message is the timestamp, a dot and the raw request body, exactly as received: <timestamp>.<raw body>.
- Read the raw body bytes before you parse any JSON. A body that was parsed and written out again no longer matches.
- Read the
X-HDI-TimestampandX-HDI-Signatureheaders. - Compute the HMAC-SHA256 of
<timestamp>.<raw body>with your signing secret, in hex, and putsha256=in front. - Compare the result with
X-HDI-Signaturein constant time, and reject the request if the timestamp is more than 5 minutes old.
In Express, use express.raw({ type: 'application/json' }) on this route to keep the raw bytes. In Flask, call request.get_data() before request.get_json(). These two examples need no framework:
Node.js, no dependencies
const crypto = require('node:crypto');
const http = require('node:http');
// From "Show signing secret" on your partner page
const SECRET = process.env.HDI_SIGNING_SECRET;
// Reject anything older than 5 minutes
const MAX_AGE_SECONDS = 300;
function isFresh(timestamp) {
// false when the header is missing or not a number
return Math.abs(Date.now() / 1000 - Number(timestamp)) <= MAX_AGE_SECONDS;
}
function signatureOk(rawBody, timestamp, signature) {
const expected = 'sha256=' + crypto.createHmac('sha256', SECRET)
.update(timestamp + '.').update(rawBody).digest('hex');
const a = Buffer.from(expected), b = Buffer.from(signature || '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
http.createServer((req, res) => {
const chunks = [];
req.on('data', c => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks); // the raw bytes, exactly as sent
const ts = req.headers['x-hdi-timestamp'];
const sig = req.headers['x-hdi-signature'];
if (!isFresh(ts) || !signatureOk(raw, ts, sig)) {
res.writeHead(401);
return res.end();
}
const event = JSON.parse(raw.toString('utf8'));
if (!event.test) {
// save the lead to your CRM here
console.log('new lead', event.lead.id, event.lead.hdi_score);
}
res.writeHead(200);
res.end('ok');
});
}).listen(process.env.PORT || 3000);
Python, standard library only
import hashlib, hmac, json, os, time
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["HDI_SIGNING_SECRET"] # from "Show signing secret"
MAX_AGE_SECONDS = 300 # reject anything older than 5 min
def is_fresh(timestamp):
try:
return abs(time.time() - int(timestamp)) <= MAX_AGE_SECONDS
except (TypeError, ValueError):
return False # header missing or not a number
def signature_ok(raw_body, timestamp, signature):
signed = timestamp.encode() + b"." + raw_body
key = SECRET.encode()
expected = "sha256=" + hmac.new(key, signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode(), (signature or "").encode())
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
size = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(size) # the raw bytes, exactly as sent
ts = self.headers.get("X-HDI-Timestamp")
sig = self.headers.get("X-HDI-Signature")
if not is_fresh(ts) or not signature_ok(raw, ts, sig):
self.send_response(401)
self.end_headers()
return
event = json.loads(raw)
if not event.get("test"):
lead = event["lead"] # save the lead to your CRM here
print("new lead", lead["id"], lead["hdi_score"])
self.send_response(200)
self.end_headers()
HTTPServer(("", int(os.environ.get("PORT", 3000))), Handler).serve_forever()
To test your code, set the signing secret to sample-signing-secret. Your signature check must accept this timestamp, body and signature. Leave the 5-minute rule out of this one test, because the timestamp is old. The body is shortened, and your code signs whatever bytes arrive.
timestamp: 1790000000
body: {"event":"hdi.lead.screening_completed","sent_at":"2026-10-05T09:14:03.512Z","partner":"acme-automation","test":true,"lead":{"id":"ld_552aba8660d7b1748d214a5f","email":"jane.doe@example.com","hdi_score":58}}
signature: sha256=44d97ef807bfa4f02528e228e4d966e660d1e2e63d129556daeeaf0e32361a26
What happens when delivery fails?
HDI waits up to 5 seconds for your answer. Any 2xx status counts as delivered. A network error, a timeout or any other status triggers one retry after about a second. A redirect counts as a failure, so give HDI the final URL. HDI keeps no queue: after the retry it does not send the lead again. Save the lead, answer, and do the slow work afterwards.
If your endpoint was down, catch up with the Leads API, as shown below. A lead can also arrive twice, for example when your answer was slow and HDI retried. Use lead.id to ignore a lead you already have.
How do I pull leads with the Leads API?
Send a GET request to https://api.hdi-audit.com/partner/leads with your partner key in the x-auditor-key header. The answer lists your leads from the oldest to the newest, at most limit of them: 1 to 100, 50 by default. since is an ISO 8601 time, read to the whole second: the answer starts at that second and includes the leads submitted in it. Only leads that came through your own link are returned, and the API needs an active trial or subscription.
curl -s -H "x-auditor-key: $HDI_KEY" \
"https://api.hdi-audit.com/partner/leads?limit=100"
curl -s -H "x-auditor-key: $HDI_KEY" \
"https://api.hdi-audit.com/partner/leads?since=2026-10-05T09:14:02Z&limit=100"
The answer has the leads and a next_since value:
{
"leads": [
{
"id": "ld_552aba8660d7b1748d214a5f",
"submitted_at": "2026-10-05T09:14:02.000Z",
"company": "Example Bio Ltd",
"email": "jane.doe@example.com",
"industry": "cgt",
"industry_label": "cell & gene therapy",
"hdi_score": 58,
"band": "High Risk",
"layers": { "coordination": 57, "control": 56, "cognitive": 54 },
"operational_leak_hours_month": 1290,
"results_url": "https://hdi-audit.com/results.html?email=jane.doe%40example.com&sig=-px3I8er4v-yO1W0z88RuWxB2bRi4nfxa-JugMph6nI"
}
],
"next_since": "2026-10-05T09:14:02.000Z"
}
next_since is the submitted_at of the last lead returned. When no lead is returned it is the since you sent, or null if you sent none. Because since includes its own second, the last lead of a page comes back at the top of the next page, together with any other lead of that second. Tell leads apart by id, never by time or position, and do not read the size of a page as a sign that no more leads are waiting.
Poll with an overlap. A lead can reach HDI's database a little after newer ones, and a client's browser clock can be a few minutes off, so a lead's time can fall before a cursor you already stored. Ask from a little before the cursor (the loop below goes back 600 seconds), skip every lead whose id you have already processed, and process the rest. This loop does it. It keeps the ids and the cursor in state, ends at a page with nothing new, and goes on through full pages of known leads:
const KEY = process.env.HDI_KEY; // your partner key, kept on your server
const BASE = 'https://api.hdi-audit.com/partner/leads';
const LIMIT = 100;
const OVERLAP_MS = 10 * 60 * 1000;
const minus = (iso, ms) => new Date(Date.parse(iso) - ms).toISOString();
// state = { cursor: null, seen: new Set() }: keep both in your database between runs.
// Returns the leads you have not processed yet.
async function pullLeads(state) {
const fresh = [];
let since = state.cursor ? minus(state.cursor, OVERLAP_MS) : null;
for (;;) {
let query = '?limit=' + LIMIT;
if (since) query += '&since=' + encodeURIComponent(since);
const res = await fetch(BASE + query, { headers: { 'x-auditor-key': KEY } });
if (res.status === 429) { // too many requests: wait, then try again
await new Promise(r => setTimeout(r, 10000));
continue;
}
if (!res.ok) throw new Error('Leads API answered ' + res.status);
const page = await res.json();
const unseen = page.leads.filter(lead => !state.seen.has(lead.id));
for (const lead of unseen) {
state.seen.add(lead.id);
fresh.push(lead); // save the lead to your CRM here
}
if (page.next_since) state.cursor = page.next_since;
// a page with nothing new ends the run, unless it is full: more may follow
if (unseen.length === 0 && page.leads.length < LIMIT) return fresh;
if (page.next_since === since) return fresh; // no progress
since = page.next_since;
}
}
A lead whose time lies more than 600 seconds before your cursor is not found this way. To read everything again, clear the cursor and run the loop once: the stored ids keep it from handing you a lead twice.
Keep the key on your server. Never put it in a web page, a URL or a repository.
| Status | Meaning |
|---|---|
| 400 | since is not an ISO 8601 date or time, or limit is not a whole number from 1 to 100. |
| 402 | Your trial or subscription is not active. The answer carries "reason": "subscription_inactive". |
| 403 | The key is wrong, or it is not a partner key. |
| 429 | Too many requests: the limit is 60 a minute per key. Ten wrong keys in a minute from one address block that address for a minute. |
| 503 | Temporarily unavailable. Try again later. |
How do I start?
Start the 60-day free trial to get your key and your link, then open your partner page to set the Webhook URL and send a test lead. The CRM integration is part of every plan, the trial included.