Opencart · 8 min read · Updated

Meta Conversions API for OpenCart 4: Server-Side Purchase Events, Deduplication With the Pixel, and Hashing Done Right

Browser-only Meta Pixel tracking misses purchases. Send Purchase events from the OpenCart 4 server with the Conversions API: capturing fbp and fbc at checkout, normalizing and hashing customer data, matching event_id with the Pixel for deduplication, testing in Events Manager, and consent.

Opencart meta conversions API

Our guide to Meta Pixel tracking in OpenCart 4 sends ViewContent, AddToCart, InitiateCheckout, and Purchase from the browser. That’s the foundation, and it has a gap: the browser has to reach the success page and run the Pixel. Ad blockers, browser tracking protection, payment redirects that never return, and customers closing the tab all lose purchases. Meta then optimizes your ads on incomplete data.

The Conversions API (CAPI) closes the gap by sending the same events from your server. Meta’s own recommendation:

For optimal ad performance, we recommend that advertisers implement the Conversions API alongside their Meta Pixel.

Meta for Developers, Handling duplicate Pixel and Conversions API events

This post shows how to send server-side Purchase events from OpenCart 4, make Meta count each purchase once, and handle customer data the way Meta requires.

How it fits together

  1. The Pixel in the browser sends Purchase with an eventID, as now.
  2. The OpenCart server sends Purchase to the Conversions API with the same event name and an event_id with the same value.
  3. Meta receives one or both, and keeps one.

Meta’s documentation spells out the matching rule: “We determine if events are identical based on their ID and name.” The Pixel’s eventID must match the API’s event_id, and the Pixel’s event name must match event_name. There’s also a time limit: “events are only deduplicated if they are received within 48 hours”. Use the order ID, which both sides know: order_1042.

Step 1: Add the event ID to the Pixel’s Purchase call

On the success page, the Pixel’s Purchase call gets a fourth argument:

fbq('track', 'Purchase', {
  value: 127.20,
  currency: 'USD',
  content_ids: ['40'],
  content_type: 'product'
}, {eventID: 'order_1042'});

The order ID has to be read before OpenCart clears it from the session in the success page’s controller. An event on catalog/controller/checkout/success/before can read it in time; our GA4 extension uses exactly that event for its purchase data, and you can reuse the pattern for the Pixel.

Step 2: Capture browser identifiers at checkout

The server-side Purchase is sent when the order gets its first paid status. With many payment methods that happens in a payment provider’s callback, a server-to-server request where the customer’s browser isn’t involved, so there are no cookies to read. Capture what you need while the customer is still on the site.

Meta uses these browser values to match events to people, and they’re not hashed:

  • fbp: the _fbp cookie, set by the Pixel.
  • fbc: the _fbc cookie, set when someone arrives from an ad click (from the fbclid URL parameter).
  • client_ip_address and client_user_agent: OpenCart already stores both on every order (ip and user_agent columns).

Save the two cookies when the order is created, with an event on catalog/model/checkout/order.addOrder/after, which runs in the customer’s own request:

public function addOrder(string &$route, array &$args, &$output): void {
	$order_id = (int)$output;

	if (!$order_id) {
		return;
	}

	$fbp = (string)($this->request->cookie['_fbp'] ?? '');
	$fbc = (string)($this->request->cookie['_fbc'] ?? '');

	// Only keep values in Meta's documented format: fb.{subdomain_index}.{creation_time}.{value}
	$fbp = preg_match('/^fb\.\d\.\d+\.[\w-]+$/', $fbp) ? $fbp : '';
	$fbc = preg_match('/^fb\.\d\.\d+\.[\w-]+$/', $fbc) ? $fbc : '';

	$this->db->query("REPLACE INTO `" . DB_PREFIX . "meta_capi` SET `order_id` = '" . $order_id . "', `fbp` = '" . $this->db->escape($fbp) . "', `fbc` = '" . $this->db->escape($fbc) . "', `consent` = '" . (int)$this->hasConsent() . "', `sent` = '0'");
}

Create the small oc_meta_capi table (order_id, fbp, fbc, consent, sent) in your extension’s install(). hasConsent() is yours to write: it should return true only if the visitor accepted marketing cookies (see “Consent” below).

Step 3: Normalize and hash customer data

Meta matches people on hashed contact information, and is strict about it: “Our systems are designed to not accept customer information that is unhashed Contact Information, unless noted below.” Each field must be normalized before it’s hashed with SHA-256, or the hash won’t match:

FieldKeyNormalize before hashing
EmailemTrim spaces, lowercase
PhonephDigits only, with country code, no leading zeros
First / last namefn, lnLowercase, no punctuation
CityctLowercase, no spaces or punctuation
StatestLowercase; 2-letter code for US states
ZIP / postcodezpLowercase, no spaces or dashes; first 5 digits for US
CountrycountryLowercase ISO 3166-1 alpha-2, for example us
Customer IDexternal_idHashing recommended

And the opposite rule for the IP address: “The client_ip_address user data parameter must never be hashed.”

A helper that does both:

private function hash(string $value): string {
	return $value === '' ? '' : hash('sha256', $value);
}

private function userData(array $order, array $capi): array {
	$phone = preg_replace('/\D+/', '', (string)$order['telephone']);
	$zip = strtolower(str_replace([' ', '-'], '', (string)$order['payment_postcode']));

	if (strtoupper((string)$order['payment_iso_code_2']) == 'US') {
		$zip = substr($zip, 0, 5);
	}

	$data = [
		'em'                => [$this->hash(strtolower(trim($order['email'])))],
		'ph'                => [$this->hash(ltrim($phone, '0'))],
		'fn'                => [$this->hash(preg_replace('/[^\p{L}]/u', '', mb_strtolower($order['firstname'])))],
		'ln'                => [$this->hash(preg_replace('/[^\p{L}]/u', '', mb_strtolower($order['lastname'])))],
		'ct'                => [$this->hash(preg_replace('/[^\p{L}]/u', '', mb_strtolower((string)$order['payment_city'])))],
		'zp'                => [$this->hash($zip)],
		'country'           => [$this->hash(strtolower((string)$order['payment_iso_code_2']))],
		'external_id'       => [$this->hash((string)$order['customer_id'])],
		'client_ip_address' => $order['ip'],
		'client_user_agent' => $order['user_agent'],
		'fbp'               => $capi['fbp'],
		'fbc'               => $capi['fbc']
	];

	// Drop empty values rather than sending hashes of nothing.
	return array_filter($data, fn ($value) => is_array($value) ? $value[0] !== '' : $value !== '');
}

Phone numbers need the country code. OpenCart’s telephone field is free text; if customers type local numbers, convert them with the address country first (libphonenumber does this well). A wrong phone hash doesn’t break anything; it just doesn’t match.

Step 4: Send the Purchase event

When the order first reaches a processing status, send the event. Use an event on catalog/model/checkout/order.addHistory/after:

public function addHistory(string &$route, array &$args, &$output): void {
	$order_id = (int)$args[0];

	if (!in_array((int)$args[1], (array)$this->config->get('config_processing_status'))) {
		return;
	}

	$capi = $this->db->query("SELECT * FROM `" . DB_PREFIX . "meta_capi` WHERE `order_id` = '" . $order_id . "' AND `sent` = '0' AND `consent` = '1'")->row;

	if (!$capi) {
		return;
	}

	$this->load->model('checkout/order');

	$order = $this->model_checkout_order->getOrder($order_id);
	$products = $this->model_checkout_order->getProducts($order_id);

	$event = [
		'event_name'       => 'Purchase',
		'event_time'       => time(),
		'event_id'         => 'order_' . $order_id,
		'action_source'    => 'website',
		'event_source_url' => $order['store_url'] . 'index.php?route=checkout/success',
		'user_data'        => $this->userData($order, $capi),
		'custom_data'      => [
			'currency'     => $order['currency_code'],
			'value'        => round((float)$order['total'] * (float)$order['currency_value'], 2),
			'content_ids'  => array_map('strval', array_column($products, 'product_id')),
			'content_type' => 'product',
			'num_items'    => array_sum(array_column($products, 'quantity')),
			'order_id'     => (string)$order_id
		]
	];

	$curl = curl_init('https://graph.facebook.com/v26.0/' . $this->config->get('module_meta_capi_pixel_id') . '/events?access_token=' . urlencode((string)$this->config->get('module_meta_capi_token')));

	curl_setopt_array($curl, [
		CURLOPT_POST           => true,
		CURLOPT_POSTFIELDS     => json_encode(['data' => [$event]]),
		CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
		CURLOPT_RETURNTRANSFER => true,
		CURLOPT_TIMEOUT        => 5
	]);

	$response = curl_exec($curl);

	if ((int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE) == 200) {
		$this->db->query("UPDATE `" . DB_PREFIX . "meta_capi` SET `sent` = '1' WHERE `order_id` = '" . $order_id . "'");
	} else {
		$this->log->write('Meta CAPI: order ' . $order_id . ': ' . substr((string)$response, 0, 300));
	}
}

Details that matter:

  • Graph API version: Meta’s current examples use v26.0. Pin a version and update it when Meta deprecates it.
  • Timing: Meta recommends sending “as soon as they occur and ideally within an hour”. Events can be up to 7 days old, but if any event_time in a request is older than that, Meta rejects the whole request.
  • The access token is generated in Events Manager (your dataset/Pixel > Settings > Conversions API > Generate access token). Store it as a setting, never in JavaScript.
  • Don’t block checkout: the 5-second timeout and logging keep a Meta outage from affecting customers. For more resilience, send through our order webhooks extension to a separate service that retries.

Step 5: Test before trusting it

  1. In Events Manager, open your dataset and the Test events tab, and copy the test code (for example TEST12345).
  2. Add "test_event_code": "TEST12345" at the top level of the request body (next to data), and place a test order.
  3. The event should appear within seconds, marked as received from the server, alongside the browser event, with deduplication noted.
  4. Check the Event match quality score after a few days of real orders; more correctly hashed fields mean better matching.
  5. Remove test_event_code. Meta’s docs are explicit: “You need to remove it when sending your production payload.”

Server-side tracking doesn’t remove consent obligations. If a visitor declined marketing cookies, don’t send their purchase to Meta from the server either; that’s why the code stores a consent flag at checkout and only sends when it’s set. In the EU and UK this matters legally; elsewhere it’s still what customers expect. Our Google Consent Mode v2 guide explains the consent categories, and your privacy policy should name Meta as a recipient.

Beyond Purchase

Purchase is where server-side sending pays off most. You can send other events the same way (InitiateCheckout from the checkout page request, Lead from the contact form), as long as each has an event ID that the Pixel sends too. Keep the number of events small and accurate rather than large and duplicated.

For the analytics side, compare Meta’s reported conversions with your OpenCart orders monthly; our post on why analytics and ad platform numbers don’t match explains the gaps that remain.

Rupak Nepali
Written byRupak Nepali

Author of four Opencart book.
The recent are Opencart 4 developer book and Opencart 4 user manual

Leave a Reply

Your email address will not be published. Required fields are marked *