pixellint

CAPI · PMs · marketers · engineers

The conversion happened in Salesforce

A lead pixel on the form and a CRM upload when the deal closes can be two events or one, depending on whether you meant to optimize for leads or for revenue. Decide before you connect Zapier to CAPI.

File it as offline, not as a fake website

Meta action_source for a store or CRM close is physical_store, phone_call, or other, not website. website plus your HQ IP looks like a traffic spike from the office. Pinterest uses offline. Snap uses OFFLINE. Reddit v3 uses PHYSICAL_STORE. LinkedIn CAPI does not use Meta's action_source enum; it uses conversion as urn:lla:llaPartnerConversion:ID and conversionHappenedAt in milliseconds.

Do not copy a website CAPI helper and change only the event name. You will be required to send event_source_url you do not have, and you will match on the wrong graph.

Clock: conversion time, not upload time

Send the time of the conversion, not the time of the CSV. A Monday upload of Friday's store sales with event_time=now attributes to Monday campaigns. Meta event_time is 10-digit seconds and can be up to 7 days old. LinkedIn conversionHappenedAt is 13-digit milliseconds. Google Ads UploadClickConversions uses conversionDateTime as yyyy-mm-dd hh:mm:ss+|-hh:mm, not ISO 8601 and not Unix.

A seconds timestamp on LinkedIn reads as 1970 and fails the age check immediately (vendor.linkedin-conversions-api.body.conversionHappenedAt.invalid). Date.now() is correct for LinkedIn and wrong for Meta. Convert per vendor at the edge.

Identity without cookies

You will not have _fbp. You should have email, phone, or a click id you stored on the lead. Hash to the vendor's spec. Do not upload a spreadsheet of raw emails to a partner that asked for SHA-256. vendor.meta-conversions-api.body.unhashed_email and vendor.linkedin-conversions-api.body.unhashed_email are that leak.

Google Ads click conversion upload needs one of gclid, gbraid, wbraid, or userIdentifiers (vendor.google-ads-click-conversions.body.click_id_or_user_required). hashedEmail and hashedPhoneNumber must be SHA-256. Enhanced conversions for leads are this path, not the website pixel.

Windows and maximum age

The attribution window (report setting) and the maximum upload age (API setting) are not the same. Meta will drop an event_time older than about 7 days before send. LinkedIn rejects conversionHappenedAt older than 90 days. A 90-day CRM lag will silently miss Meta. Promise closed-won attribution only after you have checked the vendor's maximum event age, not after Zapier returned 200.

Keep late closes in the warehouse with honest timestamps. Do not bump event_time to sneak past the age check.

Dedup against the lead pixel

If you fired Lead on the form and you upload a Purchase or a closed-won conversion later, those are two events unless you intended two optimizations. If you intended one, reuse the event_id from the browser Lead only when the vendor's event_name is also the same, which it usually is not. More often you want Lead for one campaign and a separate offline conversion action for revenue, with a different id.

What you must not do is upload the same closed-won row every night with a new id. That is a retry bug wearing a CRM hat.

LinkedIn milliseconds, Meta seconds

Same Salesforce close, two clocks. conversionHappenedAt is Date.now(). Meta event_time is Math.floor(Date.now()/1000).

// Meta offline-ish CRM close (seconds, hashed email, not website)
{
  "event_name": "Purchase",
  "event_time": 1770000000,
  "event_id": "opp-9912",
  "action_source": "phone_call",
  "user_data": { "em": ["64-char-sha256-hex"], "ph": ["64-char-sha256-hex"] },
  "custom_data": { "value": 12000, "currency": "USD" }
}

// LinkedIn CAPI (milliseconds, URN, userIds required even if empty)
{
  "conversion": "urn:lla:llaPartnerConversion:123456",
  "conversionHappenedAt": 1770000000000,
  "user": { "userIds": [], "userInfo": { "hashedEmails": ["64-char-sha256-hex"] } }
}

pixellint validate json @li.json --rulepack vendor/linkedin-conversions-api
# vendor.linkedin-conversions-api.body.conversionHappenedAt.invalid  (10 digits)
# vendor.linkedin-conversions-api.body.conversion.invalid           (not the URN)
# vendor.linkedin-conversions-api.body.user.userIds.missing

Check the artifact

Paste the pixel URL or JSON body into the playground. Same engine as pixellint validate. Nothing leaves the tab.