Docs
Integrations

Lead attribution webhook

Post leads to Heading from your form provider, CRM, or backend, and see which AI platform sent them

Google Analytics tells you a session came from ChatGPT. It does not tell you that the session became a lead, because the lead is created in your form provider or your CRM, after the session ends.

The lead attribution webhook closes that gap. You post the lead to Heading with the referrer or utm_source you captured on the visit. Heading matches it to the AI platform and counts it in AI-attributed leads on Analytics → Traffic.

Attribution fails quietly. A sender that signs the wrong bytes returns a 401 into your own logs and nothing appears in Heading, which looks the same as a quiet month. So every attempt is recorded, accepted or rejected, and the delivery log on the Traffic page shows what happened to each one.

Before you start

You need one endpoint and one signing secret, both per property. Open Analytics → Traffic, find the Lead attribution webhook under Data sources, and select Set up. The first step of that dialog holds both values. Team owners and admins can access these credentials.

Select Copy for LLM from any setup step to copy the endpoint, payload fields, signing examples, verification steps and troubleshooting guidance. Paste the guide into your preferred LLM for help adapting it to your form provider, CRM or backend. The signing secret is excluded; copy it privately into your server environment.

The secret is derived from the property, so it does not expire and it is not rotated per property. Treat it as a server-side credential. Store it in your environment, not in your code.

Send an event

Build the JSON body

contact is the only required field. Everything else is optional, and the attribution fields are what make the lead countable.

{
  "contact": "jane@example.com",
  "email": "jane@example.com",
  "sessionId": "sess_8f2c1a",
  "landingPath": "/pricing",
  "referrer": "https://chatgpt.com/"
}

Name and stamp the delivery

Every request carries two more headers:

  • x-webhook-id — your own unique id for this delivery, up to 128 characters. A UUID is ideal. If you retry the delivery, send the same id.
  • x-webhook-timestamp — the current time in Unix seconds. Heading accepts five minutes either side of its own clock.

A signature proves a request came from you. It does not prove the request is new: anyone who captures one can post it again unchanged, and every byte still verifies. The timestamp is what bounds that, and the id is what lets a retry be a retry instead of a second lead.

Sign the exact bytes you send

Join the id, the timestamp and the raw body with dots — {id}.{timestamp}.{body} — compute the hex HMAC-SHA256 of that string with the signing secret, and send it as the x-webhook-signature header. A sha256= prefix is accepted as well.

Serialise the body once, into a variable, and sign that variable. Signing a second JSON.stringify of the same object is the most common cause of a 401: two serialisations of one object can differ by a space or a key order, and the signature covers bytes, not meaning.

The id and the timestamp are inside the signature, not beside it, so neither can be changed by anyone who does not hold the secret.

POST it to your endpoint

POST /api/webhooks/leads/{propertyId}, content-type: application/json.

Watch the delivery log

Go back to the Verify step of the setup dialog. It updates within a few seconds of your request arriving, whether the request was accepted or rejected, and a rejection names what to change.

Worked examples

Each example reads the secret from an environment variable called HEADING_WEBHOOK_SECRET. Replace ENDPOINT with the endpoint from the setup dialog.

# Name the delivery, stamp it, and sign the exact bytes you send.
WEBHOOK_ID=$(uuidgen)
TIMESTAMP=$(date +%s)
BODY='{"contact":"jane@example.com","landingPath":"/pricing","referrer":"https://chatgpt.com/"}'
SIGNATURE=$(printf '%s.%s.%s' "$WEBHOOK_ID" "$TIMESTAMP" "$BODY" \
  | openssl dgst -sha256 -hmac "$HEADING_WEBHOOK_SECRET" -r \
  | cut -d' ' -f1)

curl -X POST "$ENDPOINT" \
  -H 'content-type: application/json' \
  -H "x-webhook-id: $WEBHOOK_ID" \
  -H "x-webhook-timestamp: $TIMESTAMP" \
  -H "x-webhook-signature: $SIGNATURE" \
  -d "$BODY"
import { createHmac, randomUUID } from "node:crypto";

const ENDPOINT = process.env.HEADING_WEBHOOK_ENDPOINT;
const SECRET = process.env.HEADING_WEBHOOK_SECRET;

// Retrying? Pass the same webhookId and you get the same lead back, once.
export async function sendLead(lead, webhookId = randomUUID()) {
  // Serialise once, then sign that exact string with the id and timestamp.
  const body = JSON.stringify(lead);
  const timestamp = String(Math.floor(Date.now() / 1000));
  const signature = createHmac("sha256", SECRET)
    .update(`${webhookId}.${timestamp}.${body}`, "utf8")
    .digest("hex");

  const response = await fetch(ENDPOINT, {
    body,
    headers: {
      "content-type": "application/json",
      "x-webhook-id": webhookId,
      "x-webhook-timestamp": timestamp,
      "x-webhook-signature": signature,
    },
    method: "POST",
  });

  const result = await response.json();
  if (!response.ok) {
    // Every rejection names its reason and the fix.
    throw new Error(`${result.error}. ${result.hint}`);
  }
  return result;
}
import hashlib
import hmac
import json
import os
import time
import urllib.request
import uuid

ENDPOINT = os.environ["HEADING_WEBHOOK_ENDPOINT"]
SECRET = os.environ["HEADING_WEBHOOK_SECRET"].encode()


# Retrying? Pass the same webhook_id and you get the same lead back, once.
def send_lead(lead, webhook_id=None):
    # Serialise once, then sign those exact bytes with the id and timestamp.
    webhook_id = webhook_id or str(uuid.uuid4())
    timestamp = str(int(time.time()))
    body = json.dumps(lead, separators=(",", ":")).encode()
    message = f"{webhook_id}.{timestamp}.".encode() + body
    signature = hmac.new(SECRET, message, hashlib.sha256).hexdigest()

    request = urllib.request.Request(
        ENDPOINT,
        data=body,
        headers={
            "content-type": "application/json",
            "x-webhook-id": webhook_id,
            "x-webhook-timestamp": timestamp,
            "x-webhook-signature": signature,
        },
    )
    with urllib.request.urlopen(request) as response:
        return json.load(response)
<?php

$endpoint = getenv('HEADING_WEBHOOK_ENDPOINT');
$secret = getenv('HEADING_WEBHOOK_SECRET');

// Encode once, then sign that exact string with the id and timestamp.
// Retrying? Reuse the same id and you get the same lead back, once.
$webhookId = bin2hex(random_bytes(16));
$timestamp = (string) time();
$body = json_encode([
    'contact' => 'jane@example.com',
    'landingPath' => '/pricing',
    'referrer' => 'https://chatgpt.com/',
]);
$signature = hash_hmac('sha256', "$webhookId.$timestamp.$body", $secret);

$ch = curl_init($endpoint);
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => $body,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'content-type: application/json',
        'x-webhook-id: ' . $webhookId,
        'x-webhook-timestamp: ' . $timestamp,
        'x-webhook-signature: ' . $signature,
    ],
]);

$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

if ($status !== 200) {
    $result = json_decode($response, true);
    throw new RuntimeException($result['error'] . '. ' . $result['hint']);
}

No-code tools

Zapier, Make, and most form builders can POST JSON but cannot compute an HMAC in a plain webhook step. Use a code step (Zapier's Code by Zapier, Make's custom JavaScript) to sign the body, or post from your own backend instead.

Payload fields

FieldTypeMax lengthWhat it does
contactstring, required320The lead's email or name. Shown in the recent leads feed.
emailstring320Must be a valid email. Used as the match key when there is no sessionId.
sessionIdstring128Your own session identifier. The strongest match key.
landingPathstring2048Path the visitor landed on, for example /pricing.
referrerstring2048Referrer of the visit, as a URL or a hostname. Classifies the AI platform.
utmSourcestring256utm_source captured on the visit. Classifies the AI platform.
utmMediumstring256utm_medium captured on the visit. Carried for context, never classifies on its own.

Any other key is dropped. The response lists what it dropped in ignoredFields, and if a dropped key looks like a field under another name (utm_source for utmSource, referer for referrer), it comes back in fieldSuggestions with the field it should have been.

What happens to the lead

Heading classifies referrer first, then utmSource, against the AI platforms it knows: ChatGPT (chatgpt.com, chat.openai.com), Claude (claude.ai), Gemini (gemini.google.com, bard.google.com), Perplexity (perplexity.ai), and Microsoft Copilot (copilot.microsoft.com). Subdomains match.

  • Classified. The lead is matched to that platform and counted in AI-attributed leads for the day it arrived.
  • Not classified. The lead is stored and shown in the recent leads feed, and it is not counted as AI-attributed.

Google AI Overview is deliberately absent. It arrives from google.com with no AI-specific referrer, so we cannot verify it as AI-referred, and we would rather show you nothing than a number we cannot stand behind.

Heading deduplicates on x-webhook-id, and on nothing else. Post the same delivery id twice and the second request returns the first lead, with a duplicate_event warning and no second lead stored. Post two identical bodies under two different ids and you get two leads, because two people can fill in the same form.

The window is the property's retained delivery log, the most recent 50 attempts. Beyond that the timestamp window is what stops a replay: a delivery signed more than five minutes ago is rejected outright.

Responses

A successful request returns 200 with the stored lead:

{
  "leadId": "0f5b…",
  "matched": true,
  "platform": "chatgpt",
  "warnings": [],
  "ignoredFields": [],
  "fieldSuggestions": []
}

Every rejection returns the same three keys: error (what happened), hint (what to change), and reason (a stable code you can branch on).

StatusreasonCauseFix
400missing_event_idNo x-webhook-id header, or one over 128 characters.Add your own unique id for the delivery, and repeat it on a retry.
400missing_timestampNo x-webhook-timestamp header.Add the current time in Unix seconds, and sign it with the body.
400stale_timestampThe timestamp is more than five minutes from Heading's clock.Stamp each request as you send it, and check the clock on the signing machine.
401missing_signatureNo x-webhook-signature header.Add the header.
401invalid_signatureThe signature does not match {id}.{timestamp}.{body}.Sign the exact bytes you send, with this property's secret.
400invalid_jsonThe body is not valid JSON.Send a JSON object, and set content-type: application/json.
400invalid_payloadA field failed validation. details names each one.Correct the named fields. Only contact is required.
413body_too_largeThe body is over 64 KB.Send only the lead fields above.
404noneThe property id in the URL does not exist.Copy the endpoint again from the setup dialog.
500storage_failedThe lead could not be stored.Nothing on your side. Send it again.
503noneThe webhook is not configured on this deployment.Contact us.

Retry on 500 and 503, reusing the same x-webhook-id and re-stamping and re-signing with a fresh timestamp. Do not retry a 401 or 413: the same request will be rejected the same way. Of the 400s, only stale_timestamp is worth retrying, and only with a new timestamp.

Verify that it is working

The Webhook delivery log panel on the Traffic page holds the most recent 50 attempts for the property, and shows the 20 newest. Each row carries the response code, what the endpoint did with it, and, for a rejection, the reason and the fix.

The integration card above it reads its status from the same log:

StatusMeaning
Not verifiedNothing has ever arrived. Setup is not finished.
Rejecting eventsAttempts are arriving and every one is being rejected. No lead has been stored.
VerifiedThe most recent attempt was accepted and stored.

Request bodies are kept only for attempts that passed the signature check, so an unsigned caller cannot write content into your dashboard. A 401 row shows the reason and no body.

What this does not do

  • It does not send anything to you. There are no outbound webhooks from Heading yet.
  • It does not queue or retry on your behalf. If your request fails, your sender retries it.
  • It does not rotate secrets per property. Rotating requires a deployment-wide change, which rotates every property's secret at once.
  • It does not accept utm_source and other snake_case keys. It tells you when you sent them, and drops them.