pixellint

CAPI · engineers · marketers

CAPI returned 200. Test Events is still empty.

Short answer

The Graph API, TikTok Events API, and Reddit CAPI will accept a malformed conversion and still return 200. Empty Test Events is a body bug more often than a dead token. Lint the JSON. Then look at Events Manager.

Validate this snippet Open the rulepack

Read the JSON, not the status line

Meta often returns 200 with per-event errors in the body, or accepts the batch and reports later. A gateway 200 in front of CAPI means nginx was up. Your worker logged success because the proxy was up. The Graph API never saw a legal event, or it saw one and parked it outside the attribution window. PostHog documents that capture still 200s when event or distinct_id is missing and the row is not ingested. Segment track is the same smile. GA4 Measurement Protocol collect returns 204.

If Test Events is empty, the usual miss is not the access_token. Rotating the token is the expensive debug. The cheap debug is the body: event_time in the wrong unit, user_data missing, action_source omitted, website without event_source_url, Purchase without value, test_event_code left on or left off. pixellint validate json on the body you actually POST. Do not wait an hour for Ads Manager to catch up to a clock bug.

A diagnostic order that finishes today

1. Copy the exact JSON the worker sent, not a reconstructed fixture. 2. Confirm the host: graph.facebook.com events edge, business-api.tiktok.com, ads-api.reddit.com. The wrong host with a Meta-shaped body is a 404 or a 200 from someone else's ingest. 3. Lint with the pack for that host. 4. Fix clock, hashing, action_source, event_source_url, money fields. 5. Send once with test_event_code in QA. 6. Confirm Test Events, then Pixel Helper on the same event_id. 7. Only then rotate a token.

A 401 or 403 is a token or permission problem. A 400 is usually the body, and retrying it will not help. A 429 is backoff, same body. A 500 is retry the same event_id, same event_time. Do not mint a new id on the third attempt. See CAPI retries and idempotency.

Count the digits on the clock

Meta CAPI event_time is Unix seconds, exactly 10 digits. Date.now() is 13 digits. A 13-digit value lands tens of thousands of years in the future, outside Meta's window (event_time can be up to 7 days old). vendor.meta-conversions-api.body.event_time.invalid is that miss. Pinterest uses the same seconds clock. Reddit CAPI v3 event_at is the inverse: 13 digits. LinkedIn conversionHappenedAt is 13 digits. GA4 timestamp_micros is 16 digits. TikTok Events API timestamp is ISO 8601; an epoch number is stamped as arrival time, so a backfill looks like it happened just now.

Copying one helper across vendors is how Meta attributes and Reddit files the purchase in 1970. Convert at the edge. Store one UTC instant on the order. Floor ms/1000 for Meta event_time. Date.now() for Reddit and LinkedIn. toISOString for TikTok. Empty Test Events plus a 13-digit event_time is not a token rotation problem.

{
  "data": [{
    "event_name": "Purchase",
    "event_time": 1770000000000,
    "action_source": "website",
    "user_data": { "em": "buyer@example.com" }
  }]
}

pixellint validate json @capi.json --rulepack vendor/meta-conversions-api
# vendor.meta-conversions-api.body.event_time.invalid
# vendor.meta-conversions-api.body.unhashed_email
# vendor.meta-conversions-api.body.website_requires_source_url
# vendor.meta-conversions-api.body.purchase_requires_value_and_currency

test_event_code is a filter, not a sandbox

On Meta, test_event_code diverts the event into the Test Events UI. It warns as vendor.meta-conversions-api.testing.test_event_code_present because it should not appear on live traffic. The access_token is still production. The Pixel ID is still production. There is no Meta sandbox graph you are safely writing to. Forgetting to send the code in QA is how you stare at an empty Test Events panel while production Events Manager quietly trains on your staff checkout.

The inverse is worse: a leftover code on live traffic means the event may never train the production model. Feature-flag the field. Do not comment it out in a shared client. CI should assert production fixtures omit it entirely, not send an empty string. Staging fixtures should contain it on purpose. TikTok and others have their own debug flags. They are not Meta's code. See Test events vs production CAPI.

Identity: hash email, not the IP

Empty user_data is a dark event. Meta requires user_data on every server event (vendor.meta-conversions-api.body.user_data.missing). Pinterest CAPI requires at least em, hashed_maids, or client_ip_address. A Purchase with nobody on it will 200 and will not optimize. Hash em and ph after normalize (trim, lowercase; phone digits toward E.164). Uppercase hex of the right input still matches on Meta: they lowercase the input, not the digest. A digest of Buyer@Example.com is a 64-character miss.

Leave client_ip_address and client_user_agent in the clear. vendor.meta-conversions-api.body.hashed_plaintext_field is a SHA-256 hex in those slots. Send the client IP, not the VPC NAT. A load-balancer address teaches the graph that every conversion comes from one office. fbp and fbc stay plaintext in fb.N.timestamp.value shape. Do not hash fbclid or ttclid. Reddit CAPI v3 allows raw or hashed email. That is not permission to send a raw address to Meta. See Event match quality and Hashing PII.

Wrong pixel, wrong name, two ids

The CAPI Pixel ID (or TikTok pixel_code, or Pinterest tag) must be the same id the browser pixel uses. A staging pixel in the worker and a production pixel in GTM is two datasets. Test Events on the production pixel will stay empty while staging Test Events looks fine, or the reverse. Confirm the id in the path or body, not the id in last week's runbook.

event_name must match including case: Purchase versus purchase is two events, so dedup never collapses them and Test Events looks fine on one pipe only. Snap wants PURCHASE. TikTok uses event, not event_name, and CompletePayment not Purchase. event_id on CAPI must equal fbq eventID (camelCase) on the pixel. Two UUIDs is two Purchases. A retry that mints a new id double-counts. If only the server pipe appears, you do not have a hybrid setup. You have CAPI. Pixel Helper will stay quiet. That can be intended.

website still needs a page URL

Meta requires event_source_url when action_source is website (vendor.meta-conversions-api.body.website_requires_source_url). Snap requires it when action_source is WEB. The field is the page the shopper saw, not https://api.example.com/capi/meta. A shared CAPI client that defaults to the incoming request URL will send the webhook path on every Purchase. Domain quality drops. Catalog reports attach to a host that never ran fbq.

Hosted checkout is the usual hole: thank-you is Shopify or Adyen, your API host is api.example.com. Send the thank-you URL the customer loaded, or the last first-party route you owned. localhost in production events is how test traffic poisons the graph. See event_source_url.

TikTok ISO, Reddit milliseconds, Meta seconds

A Conversions API that is not working on TikTok is often an epoch in timestamp. vendor.tiktok-events-api.body.timestamp.invalid. pixel_code is required. event is CompletePayment for a purchase, not Purchase. Hash context.user.email and phone_number. Leave context.ip and context.user_agent unhashed. event_id is required when the pixel also fires.

Reddit CAPI v3 posts to ads-api.reddit.com. event_at is 13 digits. action_source is WEBSITE, not website. A Meta helper pasted here fails the enum and the clock. LinkedIn wants conversion as urn:lla:llaPartnerConversion:ID, conversionHappenedAt in milliseconds, and user.userIds even when the list is empty. See One conversion API JSON cannot serve every vendor.

{
  "pixel_code": "CXXXX",
  "event": "CompletePayment",
  "event_id": "order-1842",
  "timestamp": "2026-08-21T18:04:00Z",
  "context": {
    "ip": "203.0.113.10",
    "user_agent": "Mozilla/5.0",
    "user": { "email": "64-char-sha256-hex" }
  }
}

pixellint validate json @tiktok.json --rulepack vendor/tiktok-events-api

Partial batches and silent 200s

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. Meta 200 with messages[].error is not success. Read the JSON. Log the per-event error next to the order id.

A nightly reconciler that resends last week's paid orders with fresh UUIDs is an incident, not a backfill. Resend with the original ids only if the vendor never acknowledged. Age still applies: Meta drops event_time older than about 7 days. Do not bump the timestamp to sneak past the cap.

Paste the body before you rotate the token

Copy the JSON from the worker log, redacted. Strip access_token, raw emails, raw phones. Keep hashed em as 64-character hex, keep event_time, keep event_id, keep action_source. Paste it into the playground as kind JSON, or run pixellint validate json --rulepack vendor/meta-conversions-api. The usual first findings are the clock, the raw email, missing event_source_url, and Purchase without value and currency. Fix those. Then watch Test Events.

Do not reuse a Meta fixture against TikTok or Reddit. Each pack is a different envelope. See Conversion API validator for the paste path, CLI, and the fixture set to keep in CI.

pixellint validate json @capi.json --rulepack vendor/meta-conversions-api
pixellint validate json @tiktok.json --rulepack vendor/tiktok-events-api
pixellint validate json @reddit.json --rulepack vendor/reddit-conversions-api

Check the artifact

Paste the pixel URL or JSON body into the playground. Same engine as pixellint validate. Artifacts you test may be stored; see privacy.