LoudPixel Docs
Reference

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)

ValueNotes
Domainthe domain registered to your account
Tenant IDyour LoudPixel tenant UUID
Pixel Secretyour 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_bucket and first_touch_ts are HMAC-covered — an attacker cannot append first_touch_bucket to a valid signature to fake an AI origin, nor tamper first_touch_ts to distort the discovery→revenue lag (the request 401s if tampered). Only ai_search is honored; first_touch_ts must 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, value rendered as a 2-decimal string, session_id is 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.

On this page