HDI Audit

HDI CRM Integration: Lead Webhook and Leads API

Updated 5 October 2026

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:

How do I set up the webhook?

Open your partner page, enter your partner key and follow these steps:

  1. Enter the URL that should receive leads in "Webhook URL" and save it.
  2. Press "Send test lead". HDI sends a sample lead marked "test": true to your URL and shows the HTTP status your endpoint answered. Status 0 means HDI could not connect.
  3. 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.
  4. 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>
FieldMeaning
eventAlways hdi.lead.screening_completed.
sent_atWhen HDI sent the request, UTC, ISO 8601.
partnerYour partner slug, the last part of your screening link.
testtrue for the sample sent by "Send test lead", false for a real lead.
lead.idA stable id: ld_ and 24 hex characters. The same submission has the same id in the webhook and in the Leads API.
lead.submitted_atWhen the client finished the screening, UTC, ISO 8601, to the whole second (.000Z).
lead.companyThe company name the client entered. Optional: not every lead has one.
lead.emailThe client's work email.
lead.industryThe sector code: cgt, atmp, cdmo, pharma, clinical or medlog.
lead.industry_labelThe sector as text: cell & gene therapy, ATMP manufacturing, CDMOs, conventional pharma, clinical research or medical logistics.
lead.hdi_scoreThe HDI score, 0 to 100. Lower is better. null if the stored answers cannot be read.
lead.bandSystemised, Managed, Moderate, High Risk or Critical.
lead.layersThe three layer scores, 0 to 100: coordination, control and cognitive. Each can be null.
lead.operational_leak_hours_monthThe hours a month the client's team spends compensating for dependency.
lead.results_urlOptional. 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>.

  1. Read the raw body bytes before you parse any JSON. A body that was parsed and written out again no longer matches.
  2. Read the X-HDI-Timestamp and X-HDI-Signature headers.
  3. Compute the HMAC-SHA256 of <timestamp>.<raw body> with your signing secret, in hex, and put sha256= in front.
  4. Compare the result with X-HDI-Signature in 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.

StatusMeaning
400since is not an ISO 8601 date or time, or limit is not a whole number from 1 to 100.
402Your trial or subscription is not active. The answer carries "reason": "subscription_inactive".
403The key is wrong, or it is not a partner key.
429Too 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.
503Temporarily 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.

Start a 60-day free trial → · See pricing →