Google Ads Enhanced Conversions for OpenCart 4: Purchase Tracking With First-Party Data, Consent Mode and a Server-Side Backup
Recover Google Ads purchases that cookies miss. Set up enhanced conversions in OpenCart 4: the purchase conversion tag with transaction_id, user_data from the order, E.164 phone numbers, Consent Mode v2, testing, and a Data Manager API backup for orders that never reach the success page.

The Google Ads conversion tag counts a purchase when the success page loads and Google can tie the visit to an ad click through a cookie. Cookies get blocked, expire, or are never set when someone clicks an ad on their phone and buys later on a laptop. Those sales happen, but Google Ads doesn’t see them, so Smart Bidding learns from fewer conversions than you actually got.
Enhanced conversions fill part of that gap. Along with the conversion, the tag sends customer data from the order (email, phone, name and address), which Google hashes and matches against signed-in Google accounts. This is the Google counterpart of our Meta Conversions API guide, and it’s easier, because Google’s tag does the hashing for you.
This post sets it up for OpenCart 4.1: the purchase tag, the customer data, consent, testing, and an optional server-side backup for orders whose customers never return to your store.
What changed in 2026
If you read an older guide, two things are different now:
- One setting instead of three methods. Google Ads used to make you choose how enhanced conversions data arrived: Google tag, Google Tag Manager, or the API. Google’s help page now says: “Starting from April 2026, Google Ads accepts user-provided data from website tags, Data Manager, and API connections.” Enhanced conversions for web and for leads are one on/off setting, and existing accounts were migrated automatically.
- Uploads moved to the Data Manager API. From June 15, 2026, offline conversion imports and enhanced conversions for leads uploads go through the Data Manager API and are blocked in the Google Ads API. If you’re building a server-side upload now, don’t build it on
UploadClickConversions.
Before you start
- A purchase conversion action that uses the Google tag. In Google Ads, Goals > Conversions > Summary, create a website conversion for purchases, or use your existing one. Note the conversion ID (
AW-123456789) and the conversion label. - Turn on enhanced conversions in Goals > Settings (customer data settings) and accept the customer data terms. You can also switch it on or off per conversion action.
- Pick one tracking method. If the purchase conversion already fires from Google Tag Manager or a theme snippet, change it there rather than adding a second tag, or every purchase is counted twice. If you use GA4 and imported GA4 purchases into Google Ads, note Google’s limitation: conversions imported from Google Analytics aren’t supported for enhanced conversions. You need a Google Ads conversion tag.
Step 1: fire the purchase conversion with an order ID
The conversion belongs on the order success page, with the order total and the OpenCart order ID as transaction_id. The order ID matters twice: Google Ads uses it to skip duplicates when a customer reloads the page, and it’s what joins the browser conversion to any server-side upload later.
OpenCart clears the order ID from the session inside checkout/success, so read it before that controller runs with an event on catalog/controller/checkout/success/before. Our free GA4 ecommerce extension uses exactly this pattern; you can add the Google Ads conversion next to it.
public function success(string &$route, array &$args): void {
if (empty($this->session->data['order_id'])) {
return;
}
$this->load->model('checkout/order');
$order = $this->model_checkout_order->getOrder((int)$this->session->data['order_id']);
if (!$order || !$order['order_status_id']) {
return;
}
$this->session->data['gads_purchase'] = [
'order_id' => (string)$order['order_id'],
'value' => round((float)$order['total'] * (float)$order['currency_value'], 2),
'currency' => $order['currency_code'],
'user' => $this->userData($order)
];
}
Then print the tags from a catalog/view/common/footer/after event, once:
$purchase = $this->session->data['gads_purchase'] ?? null;
if ($purchase) {
unset($this->session->data['gads_purchase']);
$flags = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE;
$html = '<script>if (typeof gtag === \'function\') {';
$html .= 'gtag(\'set\', \'user_data\', ' . json_encode($purchase['user'], $flags) . ');';
$html .= 'gtag(\'event\', \'conversion\', ' . json_encode([
'send_to' => 'AW-123456789/AbC-D_efG-h12_34-567',
'value' => $purchase['value'],
'currency' => $purchase['currency'],
'transaction_id' => $purchase['order_id']
], $flags) . ');';
$html .= '}</script>';
$output = str_replace('</body>', $html . '</body>', $output);
}
gtag('set', 'user_data', ...) comes before the conversion event, so the data is attached when the tag fires. Google’s troubleshooting advice is: “Make sure the customer data is available when the conversion tag fires.”
The gtag check means nothing is sent if the Google tag didn’t load, for example because the visitor declined cookies and your theme only loads tags after consent.
Step 2: build user_data from the order
Google’s field names for the tag:
| Data | Key | Notes |
|---|---|---|
email | Preferred. Google’s minimum is an email, or a full address, or a phone with one of those | |
| Phone | phone_number | E.164: plus sign, country code, digits only, e.g. +14155550123 |
| First name | address.first_name | |
| Last name | address.last_name | |
| Street | address.street | |
| City | address.city | |
| Region | address.region | State or province |
| Postcode | address.postal_code | |
| Country | address.country | Two-letter code, e.g. US |
Two rules from Google’s setup page change how you’d write this if you’re used to Meta:
- Send the values unhashed: “Make sure the values aren’t hashed.” The Google tag normalizes and hashes them itself. (If you prefer to hash, use the
sha256_email_addressandsha256_phone_numberkeys instead, normalized first: trimmed, lowercase, and for gmail.com addresses with the dots before the @ removed.) - If you don’t have a value, “remove the field entirely rather than leaving it blank.”
private function userData(array $order): array {
$data = [
'email' => oc_strtolower(trim((string)$order['email']))
];
$phone = $this->e164((string)$order['telephone'], (string)$order['payment_iso_code_2']);
if ($phone) {
$data['phone_number'] = $phone;
}
// Payment address is optional in OpenCart 4 (System > Settings > Option). Fall back to the shipping address.
$prefix = !empty($order['payment_postcode']) ? 'payment_' : 'shipping_';
$address = array_filter([
'first_name' => trim((string)($order[$prefix . 'firstname'] ?: $order['firstname'])),
'last_name' => trim((string)($order[$prefix . 'lastname'] ?: $order['lastname'])),
'street' => trim((string)$order[$prefix . 'address_1']),
'city' => trim((string)$order[$prefix . 'city']),
'region' => trim((string)$order[$prefix . 'zone_code']),
'postal_code' => trim((string)$order[$prefix . 'postcode']),
'country' => strtoupper((string)$order[$prefix . 'iso_code_2'])
], fn ($value) => $value !== '');
// Google needs first name, last name, postcode and country for an address to be usable.
if (isset($address['first_name'], $address['last_name'], $address['postal_code'], $address['country'])) {
$data['address'] = $address;
}
return $data;
}
Phone numbers in E.164
OpenCart’s telephone field is free text: customers type (415) 555-0123, 0412 345 678 or +44 20 7946 0958. Google wants + and the full international number, and Google’s guidance says the number “must be 11 to 15 digits including a plus sign (+) prefix and country code.” A number that’s wrong doesn’t break the conversion; it just doesn’t match, so it’s better to send nothing than a guess.
A small converter that covers the common cases:
private function e164(string $telephone, string $country): string {
$telephone = trim($telephone);
$digits = preg_replace('/\D+/', '', $telephone);
if ($digits === '') {
return '';
}
// Already international: +44..., or 0044...
if (str_starts_with($telephone, '+')) {
$number = $digits;
} elseif (str_starts_with($digits, '00')) {
$number = substr($digits, 2);
} else {
// Country calling codes for the countries you sell to.
$codes = ['US' => '1', 'CA' => '1', 'GB' => '44', 'AU' => '61', 'NZ' => '64', 'IN' => '91', 'NP' => '977', 'DE' => '49', 'FR' => '33'];
if (!isset($codes[strtoupper($country)])) {
return '';
}
$code = $codes[strtoupper($country)];
// Drop the national trunk prefix (0412... in Australia, 020... in the UK).
$national = ltrim($digits, '0');
$number = str_starts_with($national, $code) && strlen($national) > 10 ? $national : $code . $national;
}
return preg_match('/^\d{10,14}$/', $number) ? '+' . $number : '';
}
If you sell worldwide, use libphonenumber for PHP instead; it knows every country’s rules. A better long-term fix is to validate the phone number at checkout so the data is clean before it’s stored.
Step 3: Consent Mode v2
Enhanced conversions send personal data to Google, so they depend on consent. With Consent Mode v2, the signal that matters here is ad_user_data: when it’s denied, the Google tag doesn’t send user-provided data. In the EEA and UK, Google requires consent signals for its ads features to use this data at all.
Set defaults before the Google tag loads, and update them when the visitor chooses in your cookie banner:
gtag('consent', 'default', {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied'
});
// Later, when the visitor accepts marketing cookies:
gtag('consent', 'update', {
ad_storage: 'granted',
ad_user_data: 'granted',
ad_personalization: 'granted'
});
OpenCart’s own cookie policy notice (System > Settings > Option > Cookie Policy) doesn’t send Consent Mode signals; it only decides whether analytics extensions load. If you advertise in Europe, use a consent banner that supports Consent Mode v2, and mention Google Ads in your privacy policy as a recipient of customer data.
Step 4: test it
- Open your store in a private window from a Google Ads test URL with
?gclid=TEST(or any page), accept cookies, and place a test order with Cash On Delivery or Bank Transfer. - With Tag Assistant connected, check that the
conversionevent fired once on the success page, with yourtransaction_idand value. - In the browser’s developer tools, Network tab, filter for
googleadservices.com/pagead/conversionand open the request. Theemparameter carries the hashed data. Google’s help notes that if the data wasn’t available, you’ll seetv.1~emwith no hash after it: that meansuser_datawas empty or set too late. - Reload the success page. No second conversion should fire (the session value is removed after the first print).
- After a few days of real orders, open the purchase conversion action in Google Ads and check its enhanced conversions diagnostics for match rate and errors.
Step 5 (optional): a server-side backup for orders that never come back
Some purchases never reach the success page: the customer pays on PayPal or a bank’s page and closes the tab, or the payment is confirmed hours later by bank transfer. The browser tag can’t count those. If they’re a meaningful share of your orders, upload them from the server.
The plan that avoids double counting:
- Save the click IDs. When a visitor lands with
gclid,gbraidorwbraidin the URL, keep it in the session, and copy it to your own table with the order ID oncatalog/model/checkout/order.addOrder/after. - Mark orders the browser counted. In the success-page event above, set a flag for that order ID.
- Upload the rest. A cron job, an hour after each order reaches a paid status, uploads orders that have a click ID or customer data but no browser flag.
- Use a separate conversion action of type import for these uploads (for example “Purchase – server”), so you can see how many conversions the backup adds.
Uploads go to the Data Manager API’s events:ingest endpoint (https://datamanager.googleapis.com/v1/events:ingest), authorised with OAuth 2.0 and the https://www.googleapis.com/auth/datamanager scope. The request names your Google Ads account and conversion action, and each event carries the click ID, the order and hashed customer data. Unlike the browser tag, here you hash: SHA-256, hex encoded, after trimming and lowercasing, with phone numbers in E.164:
{
"destinations": [{
"operatingAccount": {"accountType": "GOOGLE_ADS", "accountId": "1234567890"},
"productDestinationId": "CONVERSION_ACTION_ID"
}],
"encoding": "HEX",
"consent": {"adUserData": "CONSENT_GRANTED", "adPersonalization": "CONSENT_GRANTED"},
"events": [{
"adIdentifiers": {"gclid": "Cj0KCQjw..."},
"transactionId": "1042",
"eventTimestamp": "2026-10-28T14:07:01+00:00",
"conversionValue": 127.20,
"currency": "USD",
"eventSource": "WEB",
"userData": {
"userIdentifiers": [
{"emailAddress": "<sha256 of ana@example.com>"},
{"phoneNumber": "<sha256 of +14155550123>"}
]
}
}],
"validateOnly": true
}
Send with "validateOnly": true until the response has no errors, then remove it. Only upload orders where the customer gave consent, and send their actual consent state, not a hard-coded “granted”. Google’s Data Manager API guide for Google Ads offline conversions has the full field reference.
To keep this out of the checkout request, send the upload from a separate worker; our order webhooks extension can hand paid orders to one.
What to expect
Enhanced conversions don’t create sales; they make Google Ads see more of the ones you already have, so expect reported conversions to rise modestly over the first few weeks, mostly in cross-device and returning-visitor paths. Google Ads will still not match Reports > Sales in OpenCart exactly: attribution windows, view-through rules and consent all differ. Our post on why analytics and ad platform numbers don’t match explains the gaps that remain.
For the analytics side, set up GA4 ecommerce events; for Meta ads, add the Conversions API next to the Pixel. If you tag your own campaigns, keep the parameters consistent with a UTM naming convention.







