Paid add-on. Marketing is licensed separately from the open-source packages. See pricing
Ad audiences
Turn a CRM list into an audience on Google Ads, Meta or LinkedIn by downloading a file of hashed email addresses, and what Sync Now does.
An ad audience sync (Odden\Marketing\Models\AdAudienceSync) ties a list to an advertising platform: platform is google, meta or linkedin, list_id is the source list, and audience_id is the audience's id on the platform if you want to note it. In the panel it is the Ad Audience Sync Bridges resource.
Sync Now
Sync Now runs PublishesAdAudience, whose built-in implementation is SyncAdAudienceAction. It refreshes the list if it is an active list, computes a SHA-256 hash of each member's email address (and of each address's domain), leaving out addresses that are unsubscribed, bounced or on the suppression list (as the file below does), and records the count and the time. It does not contact Google, LinkedIn or Meta. To upload an audience without connecting anything, download the file below. To upload it automatically, bind another implementation of Odden\Marketing\Contracts\PublishesAdAudience.
Download a hashed file
The Download hashed file row action gives you a CSV to upload by hand, with a header and one hash per line. Choose the platform it is for (it starts on the sync's own platform):
| Platform | Header | Hash | Normalised before hashing |
|---|---|---|---|
| Google Ads (Customer Match) | Email |
SHA-256, lowercase hex | trimmed and lowercased; dots before the @ removed for gmail.com and googlemail.com |
| Meta Ads (Custom Audience) | email |
SHA-256, lowercase hex | trimmed and lowercased |
| LinkedIn Ads (Matched Audiences) | email |
SHA-256, uppercase hex | lowercased, with no whitespace |
These follow each platform's own help pages as of October 2026. If a platform rejects a file, check its current requirements first. LinkedIn needs at least 300 rows, and Google and Meta also have minimum list sizes before an audience can be used.
Who is in the file:
- Contacts who unsubscribed or bounced (
MarketingSubscription), or whose address is on the suppression list (EmailSuppression), are left out. - Contacts with no email address are skipped.
- If
ads.require_consent_propertyis set, contacts whose custom property of that name isn't truthy are left out (see ad consent). - Addresses that give the same hash after normalising appear once. Google treats
john.smith@gmail.comandjohnsmith@gmail.comas one person, so the Google file has one line for the two. - An active (rule-based) list is refreshed first, as Sync Now does.
The file is written as it is read, so a long list does not have to fit in memory. An empty list gives a notice and no file.
Hashing an address does not make it anonymous: privacy law generally still treats a hashed email address as personal data. Only upload people you have the right to use for advertising. An unsubscribe from email is not the same as consent to advertising, which is why the file leaves unsubscribed contacts out, but that does not replace asking.
Keeping a record of downloads
After the file has been sent, Odden\Marketing\Events\AdAudienceExported is dispatched with the sync, the platform format, how many addresses were included, and how many contacts were left out as suppressed. A host application can listen for it to record who took the file.
use Odden\Marketing\Events\AdAudienceExported;
Event::listen(AdAudienceExported::class, function (AdAudienceExported $event): void {
Log::info('Ad audience file downloaded', [
'sync' => $event->sync->name,
'format' => $event->format,
'included' => $event->included,
'suppressed' => $event->suppressed,
]);
});Building the file in code
Odden\Marketing\Support\AdAudienceFile builds the same file:
use Odden\Marketing\Support\AdAudienceFile;
$file = new AdAudienceFile($sync, 'google');
foreach ($file->hashes() as $hash) {
// one hash per person; $file->included and $file->suppressed are complete once it has been read through
}
return $file->stream(); // a CSV download with a name like buyers-google-hashed-2026-10-04.csvAd consent
Consent to advertising is recorded as an opt-in on a subscription topic. Create a topic for it (for example advertising, with default off) and name its slug in ODDEN_MARKETING_ADS_CONSENT_TOPIC (odden-marketing.ads.consent_topic):
ODDEN_MARKETING_ADS_CONSENT_TOPIC=advertisingThen Sync Now and the hashed file include only contacts who have an explicit opted-in record for that topic. No record, an opted-out record, or a topic that doesn't exist all mean no consent, whatever the topic's default is, so nobody is exported until they have opted in. People can opt in through the preference center, where the topic appears like any other, or you can record it from a form with MarketingSubscriptionTopic::setSubscription($email, $topicId, true, $contactId). This is on top of the unsubscribed, bounced and suppressed contacts, who are never exported. AdAudienceFile::$withoutConsent counts the contacts left out for lack of consent.
Turning this on empties your audiences until contacts opt in: consent to marketing email does not cover advertising.
Consent in a custom property
An older alternative records consent in a custom contact property. Name that property in ODDEN_MARKETING_ADS_CONSENT_PROPERTY (odden-marketing.ads.require_consent_property):
ODDEN_MARKETING_ADS_CONSENT_PROPERTY=ads_consentThen Sync Now and the hashed file include only contacts whose properties.ads_consent is truthy: true, 1, or the strings "1", "true", "yes" or "on" (any case). A contact without the property, or with any other value, is left out, on top of the unsubscribed, bounced and suppressed contacts. The default, null, requires no consent property. AdAudienceFile::$withoutConsent counts the contacts left out this way.