Reference — Conversions API (CAPI) signing
Send server-side conversions (e.g. a Stripe webhook) to LoudPixel with an HMAC signature so they feed AI-search revenue attribution.
Send server-side conversions (e.g. a Stripe webhook) to LoudPixel with an HMAC signature so they feed AI-search revenue attribution. This is the only path that attributes a backend conversion to the AI engine that first surfaced the buyer — the browser pixel and the HTTP referrer are both gone by the time a payment fires on your server.
Only signed goal events feed the Revenue-Pixel ranking (anti-fraud). The browser lp.track() path is unsigned and is a best-effort hint only.
What you need (Dashboard → Attribution Pixel → Server-side signing)
| Value | Notes |
|---|---|
| Domain | the domain registered to your account |
| Tenant ID | your LoudPixel tenant UUID |
| Pixel Secret | your per-tenant HMAC key — secret, server-side only |
Step 1 — capture the AI first-touch in the browser, at signup
window.lp.firstTouch() returns the AI engine that surfaced this visit (in-memory only — LoudPixel writes no cookie or localStorage). Read it on your signup form and store it on your own account record:
// On signup submit:
var ft = window.lp && window.lp.firstTouch && window.lp.firstTouch();
// Persist ft && ft.bucket (e.g. 'ai_search') on the user/account row in YOUR db.When the conversion later fires server-side, read that stored value and pass it as first_touch_bucket on the signed goal.
Step 2 — sign and POST from your backend
The signed bytes (the "canonical") use sorted keys at every level, no spaces, and value rendered as a fixed 2-decimal string (so the bytes are identical across languages). Include first_touch_bucket and first_touch_ts only when you send them — with sorted keys, first_touch_ts falls immediately after first_touch_bucket. first_touch_ts is the ISO-8601 string from window.lp.firstTouch().ts; it powers the discovery→revenue lag ("closed N days later") and is signed verbatim (no normalization needed).
Python (byte-exact to the server)
import hmac, hashlib, json, requests
LP_DOMAIN = "yoursite.com"
LP_TENANT_ID = "your-tenant-uuid"
LP_PIXEL_SECRET = bytes.fromhex("your_hex_pixel_secret")
def _canonical(event_name, session_id, props, first_touch_bucket=None,
first_touch_ts=None):
props = dict(props or {})
if props.get("value") is not None:
props["value"] = f"{float(props['value']):.2f}" # money -> "99.00"
payload = {
"tenant_id": LP_TENANT_ID,
"event_name": event_name,
"session_id": session_id or "",
"properties": props,
}
if first_touch_bucket:
payload["first_touch_bucket"] = first_touch_bucket
if first_touch_ts: # ISO string, signed verbatim
payload["first_touch_ts"] = first_touch_ts
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
def track_conversion(event_name, session_id, value=None, currency=None,
plan=None, first_touch_bucket=None, first_touch_ts=None):
props = {}
if value is not None: props["value"] = float(value)
if currency is not None: props["currency"] = currency
if plan is not None: props["plan"] = plan
sig = hmac.new(LP_PIXEL_SECRET,
_canonical(event_name, session_id, props, first_touch_bucket,
first_touch_ts),
hashlib.sha256).hexdigest()
body = {"event_name": event_name, "session_id": session_id, "properties": props}
if first_touch_bucket:
body["first_touch_bucket"] = first_touch_bucket
if first_touch_ts:
body["first_touch_ts"] = first_touch_ts
return requests.post(
f"https://loudpixel.ai/api/v1/track/pixel/goal?domain={LP_DOMAIN}",
headers={"Content-Type": "application/json", "X-LP-Goal-Signature": sig},
json=body, timeout=5,
)Node
JSON.stringify does not sort keys, so build the canonical string explicitly (value as a quoted 2-decimal string):
const crypto = require("crypto");
const LP_DOMAIN = "yoursite.com";
const LP_TENANT_ID = "your-tenant-uuid";
const LP_PIXEL_SECRET = Buffer.from("your_hex_pixel_secret", "hex");
function canonical(eventName, sessionId, props, firstTouchBucket, firstTouchTs) {
props = props || {};
const top = [`"event_name":${JSON.stringify(eventName)}`];
if (firstTouchBucket) top.push(`"first_touch_bucket":${JSON.stringify(firstTouchBucket)}`);
if (firstTouchTs) top.push(`"first_touch_ts":${JSON.stringify(firstTouchTs)}`);
const p = [];
if (props.currency != null) p.push(`"currency":${JSON.stringify(props.currency)}`);
if (props.plan != null) p.push(`"plan":${JSON.stringify(props.plan)}`);
if (props.value != null) p.push(`"value":${JSON.stringify(Number(props.value).toFixed(2))}`);
top.push(`"properties":{${p.join(",")}}`);
top.push(`"session_id":${JSON.stringify(sessionId || "")}`);
top.push(`"tenant_id":${JSON.stringify(LP_TENANT_ID)}`);
return Buffer.from(`{${top.join(",")}}`, "utf8");
}
async function trackConversion(eventName, sessionId, opts) {
opts = opts || {};
const props = {};
if (opts.value != null) props.value = Number(opts.value);
if (opts.currency != null) props.currency = opts.currency;
if (opts.plan != null) props.plan = opts.plan;
const sig = crypto.createHmac("sha256", LP_PIXEL_SECRET)
.update(canonical(eventName, sessionId, props, opts.firstTouchBucket, opts.firstTouchTs))
.digest("hex");
const body = { event_name: eventName, session_id: sessionId, properties: props };
if (opts.firstTouchBucket) body.first_touch_bucket = opts.firstTouchBucket;
if (opts.firstTouchTs) body.first_touch_ts = opts.firstTouchTs;
return fetch(`https://loudpixel.ai/api/v1/track/pixel/goal?domain=${LP_DOMAIN}`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-LP-Goal-Signature": sig },
body: JSON.stringify(body),
});
}Rules and troubleshooting
- Never send PII in
properties(email/phone/SSN are rejected with HTTP 400). Hash any identifier in your backend first. - Allowed properties:
value(a finite number, 0 to 10000, at most 2 decimal places),currency(3-letter ISO),plan(string up to 50 chars). first_touch_bucketandfirst_touch_tsare HMAC-covered — an attacker cannot appendfirst_touch_bucketto a valid signature to fake an AI origin, nor tamperfirst_touch_tsto distort the discovery→revenue lag (the request 401s if tampered). Onlyai_searchis honored;first_touch_tsmust be ISO-8601 within the last 180 days, else it is ignored and the lag is left blank.- 401 invalid signature means your canonical bytes differ from the server's. Check, in order: key sort at both levels, no spaces,
valuerendered as a 2-decimal string,session_idis an empty string (not null) when absent, and the secret is decoded from hex to bytes. - One source per goal: fire a given conversion from the browser or your backend, not both.