pixellint

CAPI · engineers

Retry the body, not a new conversion

At-least-once delivery is the honest model. Exactly-once is what event_id is for. If your queue redelivers and you generate UUIDs in the publisher, you will pay for it in ROAS.

Retry the same bytes

Honor Retry-After. Exponential backoff with jitter on 429 and 5xx. Replay event_id, event_name, event_time, value, currency, user_data. Do not refresh event_time to now() on the third attempt: that is how a 500 turns into a late conversion that looks on-time and double-counts against a pixel that already fired.

Do not spin on 400. 400 means the body is wrong and will be wrong next time too. Fix event_time digits, hash em, add event_source_url, then send once. A retry storm of 400s is a linter finding you are paying Graph API to ignore.

event_id is the idempotency key

Meta collapses pixel plus CAPI on the same event_id and event_name within 48 hours on the same Pixel ID. It will also collapse two CAPI posts that share that pair, which is what you want on redelivery. A new UUID on retry disables that. Pinterest requires event_id; empty is vendor.pinterest-conversions-api.body.event_id.missing. TikTok documents event_id when both pipes fire.

HTTP is not idempotent for you. The Graph API events edge will accept the same Purchase twice if you change the id. Your queue's delivery count is not a vendor field.

Success memory

Store the vendor response against the order id: HTTP status, body messages, the event_id you sent, the event_time you sent. A nightly reconciler that resends every order from last week, with new ids, is an incident. Resend with the original ids only if the vendor never acknowledged.

Ack is not HTTP 200. Read the JSON. If Meta returned 200 with a per-event error, you have not succeeded. If PostHog returned 200 for a body missing distinct_id, you have not succeeded either, and retrying the same broken body will not ingest it.

Partial batches

If you POST an array of events and one fails, do not replay the whole array with new ids. Replay the failed index with its original id. Replaying the successes is how a 1% error rate becomes a 99% double-count on the next drain.

Keep batch size small enough that a partial failure is operable. A 1000-event Meta batch with one bad event_time is a support incident if your client can only retry all-or-nothing.

Age still applies on retry

Meta documents event_time as at most 7 days before the event is sent. A retry that sits in a dead-letter queue for eight days will not attribute even with the original id. Snap CAPI is also the last 7 days. LinkedIn conversionHappenedAt rejects events older than 90 days. Do not bump the timestamp to sneak past the age check. That attributes Friday's sales to Monday's campaigns.

Warehouse ROAS can still include the late sale. Ads UI will not. Say so. A reconciler that rewrites event_time is lying to the graph.

Redeliver, do not reinvent

uuid() in the send path turns every retry into a new Purchase. Persist the id on the order.

// Wrong: new id on each attempt
async function send(order) {
  const event_id = crypto.randomUUID();
  await capi.post({ event_name: "Purchase", event_id, event_time: Math.floor(Date.now()/1000), ... });
}

// Right: stored id, stored event_time (seconds), retry same body
async function send(order) {
  await capi.post({
    event_name: "Purchase",
    event_id: order.event_id,
    event_time: order.event_time_unix_s,
    action_source: "website",
    event_source_url: order.thank_you_url,
    custom_data: { value: order.total, currency: order.currency },
    user_data: order.meta_user_data
  });
}

Check the artifact

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