pixellint

Blog · Meta and Google

Meta CAPI vs Google enhanced conversions lines up a Graph event with a Data Manager upload.

People search meta capi vs google capi, and meta conversions api vs google enhanced conversions, because both pipes exist to recover a purchase the browser did not finish. The names sit on one shelf. The payloads do not.

Google Ads does not publish a product named Conversions API. A connector that advertises a Google CAPI is describing one of three objects: hashed user data on the Google tag (enhanced conversions), a click conversion upload, or a Data Manager ingestion event. Meta's Conversions API is one object: a JSON event posted to the Graph events edge for a Pixel or dataset.

On 15 June 2026 Google moved offline conversions import and enhanced conversions for leads uploads onto the Data Manager API and blocked that upload on the Google Ads API. Tokens that never called the Ads API from January through June 2026 do not keep a legacy exception. A worker that still posts a Meta-shaped body, or a legacy UploadClickConversions body, is the failure this comparison is about. The April 2026 settings change, one on/off for web and leads, is a different sentence on the settings page.

Google has no Conversions API to migrate onto

Meta documents a server event with event_name, event_time as a Unix timestamp in seconds, action_source, and user_data. Purchase also wants custom_data.value and custom_data.currency. Deduplication against the browser pixel uses event_id together with event_name, on the same Pixel ID. That contract has a name teams already say out loud: CAPI.

Google's server path does not reuse those keys. The Data Manager send-events guide builds an Event with eventTimestamp, transactionId, eventSource, conversionValue, currency, adIdentifiers, and userData. For Google Ads offline conversions or enhanced conversions for leads, the guide requires at least one identifier: adIdentifiers with gclid, gbraid, or wbraid, or session attributes, or userData, or a device IP. productDestinationId is the numeric id of a conversion action whose type is UPLOAD_CLICKS. There is no event_id field in that sample, and there is no action_source.

The browser side is a third object again. Enhanced conversions on the Google tag hash first-party data beside the conversion hit. They do not replace gclid. A tag that fires conversion with send_to and transaction_id is what Data Manager later joins. Calling that tag a Google CAPI is how a migration ticket gets scoped as a host change.

The 15 June 2026 cutover is a different endpoint, not a rename

Google Ads Help says that starting 15 June 2026, offline conversions import and enhanced conversions for leads uploads migrate to the Data Manager API and are blocked in the Google Ads API. Developer tokens that did not send a request from January 2026 through June 2026 are not allowlisted for the legacy path. The Ads Developer Blog dated 15 May 2026 is the same change, aimed at people still calling UploadClickConversions.

That date is easy to mix up with the settings article. Starting April 2026, Google Ads accepts user-provided data from website tags, Data Manager, and API connections at the same time. Starting June 2026, enhanced conversions for web and for leads collapse to one on/off in the account. Simultaneous ingest is not the same sentence as the API block. You can have the toggle on and still post offline clicks at a method Google has stopped accepting.

A shared worker that mapped Meta event_time into conversionDateTime on the Ads API was already wrong on format: the Ads API clock is yyyy-mm-dd hh:mm:ss with a timezone offset, not ten digits. After the cutover, the surviving clock is eventTimestamp on Data Manager. The send-events samples use strings such as 2025-06-10T23:42:33-05:00. An integer second count is not that string. GA4 Measurement Protocol is a fourth clock, timestamp_micros, and it is not this upload either.

transactionId joins the tag, and event_id does not

Inside one conversion action, Google Ads uses transactionId to tell a Data Manager event from a website tag event. If transactionId matches a tag event, conversion value and currency from the ingestion can override the tag value. Other fields on that match, including adIdentifiers.gclid, are ignored and do not overwrite what the tag already recorded. If transactionId matches nothing, Google creates a new conversion and tries to attribute it with the identifiers you sent, such as gclid or userData.

The same guide puts a 14-day trial on a new conversion action. During that trial, value overrides are disabled, and newly created conversions can show in reporting without being used for bidding. A test purchase that looks accepted on day two is not evidence the bid model saw it. Meta's dedup window is a different number on a different key: the same event_id and event_name on the same Pixel ID, within 48 hours. After that window a repeated id does not collapse the second event.

Copying event_id into a JSON key you also named event_id, on a Data Manager body, does not create the join. The website tag has to send the same transaction_id the server sends as transactionId. An order id that is event_id on Meta and a fresh UUID on the Google tag will double-count on Google and dedupe on Meta, from one checkout.

The same order, two bodies

Illustrative pair. The Meta body follows the Conversions API server event. The second body follows the Data Manager send-events sample: eventTimestamp, transactionId, currency, adIdentifiers.gclid, userData.userIdentifiers.

{
  "data": [{
    "event_name": "Purchase",
    "event_time": 1759010400,
    "event_id": "order-1842",
    "action_source": "website",
    "event_source_url": "https://shop.example/thanks",
    "user_data": { "em": ["<sha256 hex>"], "fbc": "fb.1.1759010400123.AbCdEf" },
    "custom_data": { "value": 84.50, "currency": "USD" }
  }]
}

{
  "encoding": "HEX",
  "destinations": [{
    "operatingAccount": { "accountType": "GOOGLE_ADS", "accountId": "1234567890" },
    "productDestinationId": "456"
  }],
  "events": [{
    "eventTimestamp": "2026-09-27T18:00:00Z",
    "transactionId": "order-1842",
    "eventSource": "WEB",
    "conversionValue": 84.50,
    "currency": "USD",
    "adIdentifiers": { "gclid": "EAIaIQobChMI_example" },
    "userData": {
      "userIdentifiers": [{ "emailAddress": "<sha256 hex>" }]
    }
  }]
}

Post the first JSON to graph.facebook.com and you are on Meta's pipe, if the Pixel id, the access token, and the hash are real. Post that same JSON to Data Manager and you have sent data, event_name, and event_time to an API that asked for destinations, encoding, and events. The HTTP status will not teach the field rename.

Post the second JSON to Meta and you have the mirror miss: no event_name, no event_time in seconds, no action_source, an email under userData.userIdentifiers instead of user_data.em. Order 1842 can be the shared business key. It cannot be a shared schema.

A matching transactionId updates the money and drops the click id

Walk one checkout. The Google tag on the thank-you page sends transaction_id order-1842 and value 84.50. The gclid cookie did not survive the hop to the checkout host, so that tag event has no click id. An hour later the server sends a Data Manager event with the same transactionId, the gclid stored on the landing host, and the same 84.50.

Google's table for a matching transactionId says the conversion value and currency from the ingestion override the tag. Other fields on that match do not. The guide's own example of an ignored field is adIdentifiers.gclid. The click id you finally recovered does not attach to the conversion that already exists. During the first 14 days of that conversion action, value overrides are disabled too, so the trial can show a row that neither gained the click id nor took the server's amount. After the trial, a newly created unmatched conversion becomes biddable. A matched one still does not grow a gclid it did not have.

Dropping transactionId so the server event looks new will let Google try to attribute with the gclid or the hashed user data. If the tag already counted order-1842, that is a second conversion. Meta's event_id does the other job: a repeated id and event name on the same Pixel ID, inside 48 hours, is discarded. It does not patch a missing click id onto the first event, and it does not update the value. One order id used as both keys, without knowing which rule is in force, is how the order table, Ads, and Events Manager each report a different count.

Ads, Analytics, and Floodlight do not share that join

Data Manager can list more than one destination on the same request. The send-events guide does not give those destinations one dedup rule, and it does not give them one set of required identifiers.

For Google Ads offline conversions or enhanced conversions for leads, transactionId is optional, and the event still needs an identifier: gclid, gbraid, or wbraid, or session attributes, or userData, or a device IP. For multi-source conversions, transactionId is required, and eventSource if you set it must be WEB. The match-then-update-value behavior is the Ads rule, inside one conversion action.

A GA4 web stream is the next rule over. Multi-source events require transactionId and at least one of clientId, a gclid, userId, or userData. Google Analytics keeps the information from the first instance of that event. A later server payload does not override the tag the way Ads can override value. The eventTimestamp has to fall within the last 72 hours. Events you want joined with what gtag.js or the Firebase SDK already collected have to arrive within 48 hours of the original client timestamp. Floodlight is a third rule: activity id plus transactionId. Offline Floodlight may omit transactionId. Multi-source Floodlight may not. A Meta event_id copied into one JSON key does not satisfy three required shapes, and one order id will update Ads, freeze Analytics on the first hit, and key Floodlight only when the activity id is on the event.

The conversion action id is not the send_to string

The REST sample splits identifiers that a tag manager shows as one line. operatingAccount.accountId is the account. productDestinationId is the conversion action id. loginAccount is a separate object for when the caller is not that operating account. The Google tag's send_to value looks like AW-123456789/AbC-D_efGhIj. That string is the tag label. It is not productDestinationId, and the AW- customer id is not the conversion action id.

Set validateOnly to true when you want the check without applying the event. A plain-text email is not filed as a warning in the documented failure. The response is 400, status INVALID_ARGUMENT, reason INVALID_HEX_ENCODING, on events.events[0].user_data.user_identifiers, with the description Email is not hex encoded. One bad identifier rejects the request. fieldWarnings are a different object, for optional fields that were accepted with a warning. The guide tells you to store the requestId so you can read diagnostics after each destination processes.

When gclid and wbraid are missing, the same guide tells you to add session attributes for offline conversions and enhanced conversions for leads. The recommended value is adIdentifiers.sessionAttributes, the base64 string captured on the form page. The alternative is individual fields under experimentalFields, and only if you cannot capture that string. Neither one is Meta's fbc, and neither one is a query parameter you invent and name click_id.

The Gmail address is not one hash

Data Manager's own before-and-after table lowercases zoe@EXAMPLE.COM to zoe@example.com, and it rewrites cloudy.sanfrancisco@gmail.com to cloudysanfrancisco@gmail.com before SHA-256. The period in the gmail.com local part is removed in the formatted row. The request then sets encoding to HEX. A plain-text emailAddress comes back as not hex encoded.

Meta's documented floor for user_data.em is trim and lowercase, then SHA-256. Stripping the dot because Google's table stripped it produces a digest Meta did not compute, unless the shopper typed the address without the dot. Plus-tags are the same split: hash the string checkout collected for Meta, and follow Google's formatting guide for Google. One hasher with a silent Gmail default matches one vendor.

City, region, and postal code stay raw on Google's enhanced conversions recipe. Given name and family name are hashed. Meta hashes email and phone and does not want you to hash client_ip_address or client_user_agent. A privacy pass that hashes every string in the customer object breaks both matchers in different fields.

The formatted sample does not hash the whole address. givenName and familyName become SHA-256. regionCode stays US. postalCode stays a raw code such as 94043. city stays a lowercase string such as mountain view, and administrativeArea stays ca. Meta's server user_data hashes ct, st, zp, and country along with the email. A helper that hashes every address field because that is what Meta asked for will send hex where this Google sample left the city in the clear, and a helper that leaves city in the clear on the Meta body will fail Meta's hash rule on ct.

Fields that do not survive the copy

Within the same conversion action, Google Ads uses the transactionId to deduplicate conversion events sent from different sources (like your website tag and Data Manager API ingestion requests).

Google Data Manager API: send events

Google writes the later value over the tag, and Meta throws that later value away

Put the same order on both pipes and the second event does opposite jobs. On Google Ads, inside one conversion action, a Data Manager event whose transactionId matches the tag can replace conversion value and currency. The handling table says those fields are updated. It also says the other fields on that match are ignored, with gclid as the example. After the 14-day trial on a new conversion action, that value update is allowed. During the trial it is not, and a brand-new unmatched conversion can sit in reporting without being used for bidding.

On Meta the dedup page says the later copy is discarded. The first event_id plus event_name to arrive, on that Pixel ID, inside 48 hours, is the one that remains. A thank-you pixel that fires with the cart estimate, followed two seconds later by a webhook with the captured total and the same event_id, does not update the estimate. The captured total is the subsequent event. It is dropped. If the webhook arrives first, the pixel's estimate is the one that is dropped. Arrival order is the value policy. There is no average, and there is no override.

Currency rides with that split. The Google table updates conversion value with currency. A tag event in USD and a matched server event in EUR do not stay USD after the trial. The sample event in the send-events guide is itself a EUR amount with a timestamp that carries an offset. Meta does not offer that override. Whichever Purchase arrived first keeps its currency, and the other currency is the discarded event. Finance looking at one order id will see Google move and Meta stay, from the same two payloads.

One line item, two prices

The numbers are the Data Manager guide's own example. Meta's contents object does not apply that subtraction for you.

Meta custom_data.contents[]:
  { "id": "sku-9", "quantity": 1, "item_price": 27.67 }

Data Manager cartData.items[]:
  { "itemId": "sku-9", "merchantProductId": "sku-9", "quantity": 1, "unitPrice": 21.01 }

# Item unit price 27.67, item discount 6.66.
# Google's guide: unitPrice excludes tax, shipping, and event-level discounts.
# Apply the item discount first: 27.67 - 6.66 = 21.01.

For Google Ads conversions that are not store sales, itemId and merchantProductId are required on the cart line, and unitPrice follows that rule. Store sales marks those item fields optional and still tells you to use the discounted unit price. The event-level conversionValue remains the money field the match table knows how to update. A cart of pre-discount prices under a conversionValue that is post-discount will bid toward one number and report another.

Meta's contents entries are id, quantity, and item_price as you send them. Nothing in the Conversions API subtracts 6.66 because Google did. If the pixel sends 27.67 and arrives first, that is the item price Meta keeps. The server's 21.01, if it is a second Purchase with the same event_id, is discarded. If you omit event_id, both prices count. The order id did not save you. The dedup key did, or it did not.

Twelve days later, only one of these pipes is still open

A CRM marks closed-won 12 days after the click. Finance has one row. The two graphs do not. Meta documents event_time as at most 7 days before you send it. Snap is the same 7 days. An event older than that does not sneak in because the JSON is otherwise perfect. If any event_time in a Meta batch is older than 7 days, the request errors and none of the events in it process. One stale deal in a file of yesterday's purchases drops yesterday too.

Stamping event_time with now, so the 12-day deal looks young enough, is how Friday's store sale trains Monday's campaign. The age check is against the timestamp you send, not against the day the opportunity was created in the warehouse. Keep the honest instant. If it is older than 7 days, leave it out of Meta. The warehouse still has it. Meta will not.

Google Ads offline import can still take that click-dated conversion when the click falls inside the conversion action's window, after Meta has already refused the row. The window is the one on the action, tied to the click, not a second copy of Meta's 7-day event age. GA4 is a third clock on the same Data Manager or Measurement Protocol hop: events can be backdated about 72 hours. If timestamp_micros is older than that and validation_behavior is omitted or RELAXED, GA4 accepts the hit and rewrites the timestamp to 72 hours ago. ENFORCE_RECOMMENDATIONS rejects it. A rewritten timestamp is a sale on the wrong day. Advertising joins with gtag or Firebase still want the event within 48 hours of the original client timestamp. Three products, three ages, one closed-won.

One bad row fails the request, on both graphs

Meta's 7-day rule is batch-scoped. The old event is not skipped. The fresh events beside it are not saved. Split the file before you POST. A reconciler that appends last month's unsent deals to tonight's purchases will zero out tonight.

Data Manager fails closed in a different shape, for a different field. A plain-text emailAddress is a 400 on the request, reason INVALID_HEX_ENCODING, and that request is the one that listed every destination. Ads, GA4, and Floodlight do not get a partial apply from a request Google already rejected. fieldWarnings show up on a request that was accepted. You only see them if you stored the requestId and read diagnostics per destination. A 400 never produces that id's success path.

The practical split is which defect you are allowed to isolate. On Meta, isolate by age before the batch is built. On Data Manager, isolate by encoding before the request is built, and use validateOnly when you want the rejection without writing a conversion. Neither API will tell you that the other one accepted the same order. The warehouse has to.

What to ship instead of a Google CAPI client

Keep one internal order: id, UTC instant, value, currency, email as collected, and whichever click id actually arrived. At the Meta edge, emit event_name, event_time in seconds, event_id, action_source, and user_data.em hashed after trim and lowercase. At the Google edge, emit a Data Manager event with eventTimestamp, transactionId equal to the tag's transaction_id, adIdentifiers when you stored gclid or a braid, and a user identifier hashed with Google's formatting. Do not point either client at the other host.

If the token still calls the Google Ads API for offline clicks, treat 15 June 2026 as past. Inventory whether that token sent traffic between January and June 2026. Idle tokens were the ones Help said would miss the legacy allowlist. The unified enhanced conversions switch does not move those requests for you.

Pixellint is not affiliated with Google or Meta. The Meta body is vendor/meta-conversions-api. The click upload that still looks like the Ads API is vendor/google-ads-click-conversions. Passing either check means the artifact matches that pack's contract. It does not mean Ads credited the campaign, and it does not mean a Data Manager destination accepted the event. Validate the two payloads as two artifacts.

Checks before you call the pipes interchangeable

Sources

Contract pages

The dated argument is above. These pages are the field lists.

Read the enhanced conversions contract Docs