← Back to Insights

Preference centre and GDPR: when antivirus gateways unsubscribe your customers

Pierre Frin September 2026 7 min read
un seul lien dans le pied de page Me désabonner CloudPage passerelle de sécurité GET automatique clic humain POST du formulaire désabonné choix daté Marketing Cloud Engagement · CloudPages

A travel agency network sends its trade newsletter on a Tuesday morning. Within the hour, the unsubscribe rate passes several per cent, against a few tenths on a normal send. The departures cluster on a handful of corporate domains, and they all land within minutes of the send.

Nobody clicked. The security gateways at those companies open every link in the message to analyse it, including the footer link labelled "Unsubscribe in one click". That link opened a CloudPage which unsubscribed the subscriber as soon as it loaded, then displayed a confirmation. Customers end up unsubscribed without knowing it, and wonder a few weeks later why nothing arrives any more.

The same week, a customer disputes ever agreeing to partner offers. The team can only show a ticked box in a data extension, rewritten every time she visits the page. No original date, no record of the form, no wording as it stood at the time. Both incidents share one root cause: a preference page designed as an admin screen rather than as a point of collection.

A click is not proof of intent

The Salesforce documentation on opens and clicks is explicit: antivirus scans go through the message and follow the links, and any activity, even triggered by a system, is attributed to the subscriber. On a consumer send, that inflates a click rate. On an unsubscribe page, it produces departures nobody asked for, and the effect hits business addresses hardest.

Writing only on POST

The rule that follows fits in one sentence: a GET request never changes a status or a preference. The page opened from the email displays a choice, and only submitting the form with a button, as a POST, triggers the write. Bots that follow links do not submit forms.

GET  : display the choices, create a single-use token
POST : check the token, write the preferences, unsubscribe

List-Unsubscribe, the safer exit route

The List-Unsubscribe header avoids the problem. Salesforce adds it to commercial sends, it supports the one click unsubscribe described in RFC 8058, and the mail client then sends a POST that Salesforce handles itself. You can neither disable it nor change it, and that is good news: of the two exit routes offered to the recipient, it is the safer one.

⚠️ The signal to watch: a spike in unsubscribes concentrated on a few corporate domains, within minutes of a send. It is not a rejection of your content, it is a security gateway doing its job.

What the page should leave behind

Article 7 of the GDPR sets two requirements when processing rests on consent: the controller must be able to demonstrate that the person gave it, and withdrawal must be as easy as giving it. For email marketing, the CNIL states that prior consent is the rule for private individuals, with an exception for existing customers contacted about similar products or services, and asks for a simple way to object in every message along with an objection list kept up to date.

Those texts are public, reading them belongs to your data protection officer, and this article does not replace their advice. What falls to us is turning the demonstration requirement into fields. A box set to true in a preference table says what the customer last chose. It says nothing about when, where, or against which wording.

Element of proofFieldExample
The personClientId100245
The purpose and the choiceFinalite, Valeurpartenaires, true
The momentDateEvenementServer date and time, with the time zone documented
The point of collectionSourcecentre_preferences, creation_compte, magasin
The wording shownVersionTextePART-2026-03, pointing to the wording table
The send contextJobIdIdentifier of the send that brought the customer to the page

A history written as inserts only

This history is written as inserts only, one row per purpose on every submission, even when the value does not change. The confirmation is what makes the record, not just the change. The preference table stays alongside it, as a view of the current state, handy for targeting queries.

Versioning the wording shown

The wording on screen deserves the same care. Read it from a table of versioned texts, starting from the current version, and pass that version to the form handler rather than hard coding it in the page that writes. The day the wording changes, a version frozen in the handler keeps recording the old reference, without a single error, and the record becomes false.

💡 Time zones: Now() returns the server time. Document that time zone in the data extension description, or convert the value before writing it. A record whose time zone is unknown loses much of its value.

Four scopes of unsubscribe

A refusal does not carry the same scope depending on how it is recorded, and that is the source of a common gap between the label promised to the customer and the effect obtained.

MechanismScopeNote
Preference in a data extensionNone, as long as sends do not read itApply it with an exclusion script or a targeting query
Unsubscribe from a publication listSends attached to that listThis is what the standard subscription centre does
List-Unsubscribe headerSame as the subscription centre, at list levelHandled by Salesforce, as a POST, neither disabled nor changed
LogUnsubEvent without send contextGlobal unsubscribe for the subscriberVisible in All Subscribers
LogUnsubEvent with the send contextThe list of that send, the event is attached to the jobThe unsubscribe appears in the send tracking
Business unit unsubscribeOne business unit of an Enterprise 2.0 accountVisible in _BusinessUnitUnsubscribes from the parent enterprise

A box labelled "stop all marketing emails" commits you to the widest scope. Test both variants on a test account, with and without the send context, and check what happens to the status in All Subscribers before settling on the label.

Calling LogUnsubEvent at the right moment

LogUnsubEvent is an Execute request of the SOAP API, called from AMPscript with CreateObject and InvokeExecute. It needs at least one subscriber identifier: SubscriberKey, SubscriberID or EmailAddress. When several are supplied, they must point to the same subscriber. The send context comes through JobID, ListID and BatchID, and JobID is enough since the platform finds the other two. Without a usable context, the unsubscribe becomes global. The Reason parameter is free text.

%%[
/* handler page, reached on POST, token already consumed */
SET @lue = CreateObject("ExecuteRequest")
SetObjectProperty(@lue, "Name", "LogUnsubEvent")

SET @prop = CreateObject("APIProperty")
SetObjectProperty(@prop, "Name", "SubscriberKey")
SetObjectProperty(@prop, "Value", @clientId)
AddObjectArrayItem(@lue, "Parameters", @prop)

SET @prop = CreateObject("APIProperty")
SetObjectProperty(@prop, "Name", "Reason")
SetObjectProperty(@prop, "Value", "Centre de preferences, refus total")
AddObjectArrayItem(@lue, "Parameters", @prop)

SET @code = InvokeExecute(@lue, @statut, @requestId)
]%%

Logging the return codes

Log the result, or a failure goes unnoticed. The documentation treats the status Event posted as a success, and reads codes 12012 and 401 as the sign of a subscriber already unsubscribed. A test that ignores those two codes fills the log with false failures, and a log full of false failures stops being read.

One last precaution about the send context. The personalisation strings that carry it should be populated on a page opened from a CloudPagesURL link, but not on a test send. Check their contents on your own account before relying on them, because an empty context raises no error, it only changes the scope of the unsubscribe.

LogUnsubEvent only knows how to unsubscribe. Re-subscribing, after fresh explicit consent, goes through an update of the subscriber status and deserves a separate procedure, agreed with your data protection officer.

Checking that the refusal is applied

A recorded preference is worth nothing if sends ignore it. A daily query compares full refusals with what All Subscribers says, and writes to a control table in Overwrite mode.

SELECT
    p.ClientId,
    s.Status,
    p.DateMaj
FROM Preferences AS p
INNER JOIN _Subscribers AS s
    ON s.SubscriberKey = p.ClientId
WHERE p.OptinNewsletter  = 'false'
  AND p.OptinPartenaires = 'false'
  AND s.Status = 'active'
  AND p.DateMaj < DATEADD(HOUR, -1, GETDATE())

The one hour delay avoids flagging refusals still being processed. The form of the comparison depends on the actual type of your preference fields, so test it in a Query Activity before putting the query into production. Every row left over is a refusal that was not applied, whether from a failed call, a scope other than the one expected, or a customer who unticked boxes without asking for a full refusal. That last case is not a fault, but your targeting queries have to exclude it. From the parent enterprise, the same logic applies to business unit unsubscribes.

If you are coming from Adobe Campaign

In Adobe Campaign, the global refusal maps to the recipient's blocklist field, formerly known as the blacklist, and the choices by purpose map to subscriptions to information services, whose history the platform keeps.

Marketing Cloud covers the global refusal with the status in All Subscribers and with publication lists. The consent history by purpose does not exist out of the box: building it in a data extension is on you, with the date, the source and the wording version. That is the main design load of a bespoke preference centre, and the one teams discover the day they have to answer a complaint.

The habit worth keeping

Before opening the code of your preference centre, ask one question: what happens if a bot loads this page and clicks nothing? If the answer contains a write, you have an incident waiting, and human testing will never reproduce it. The rest, the dated record, the wording versions, the scope of the unsubscribe, comes afterwards.

Frequently asked questions

Why do unsubscribes rise right after a send when nobody clicked?

Security gateways at some companies open every link in the message to analyse it, including the unsubscribe link in the footer. The Salesforce documentation states that any activity, even triggered by a system, is attributed to the subscriber. If the unsubscribe page writes as soon as it loads, those scans produce departures nobody asked for. The typical signal is a spike concentrated on a few corporate domains within minutes of the send.

How do you stop a bot from unsubscribing a contact from a CloudPage?

The rule is that a GET request never changes a status or a preference. The page opened from the email only displays a choice and creates a single-use token, and only submitting the form with a button, as a POST, triggers the token check and then the write. Bots that follow links do not submit forms.

Which fields should you keep to demonstrate consent in Marketing Cloud?

A box set to true in a preference table says what the customer last chose, but nothing about when, where, or against which wording. The article turns the demonstration requirement into fields: the person (ClientId), the purpose and the choice, the moment (DateEvenement), the point of collection (Source), the wording shown (VersionTexte) and the send context (JobId). This history is written as inserts only, one row per purpose on every submission even when the value does not change, with the preference table kept alongside as a view of the current state.

What do the 12012 and 401 codes returned by LogUnsubEvent mean?

The documentation treats the status Event posted as a success, and reads codes 12012 and 401 as the sign of a subscriber already unsubscribed. You should therefore log the result of the call, or a failure goes unnoticed. A test that ignores those two codes fills the log with false failures, and a log full of false failures stops being read.

Is a preference stored in a data extension enough to stop the sends?

No: a preference in a data extension has no scope as long as sends do not read it, so it has to be applied with an exclusion or a targeting query. Unsubscribing from a publication list covers the sends attached to that list, and the List-Unsubscribe header acts at the same level. To check that the refusal is really applied, a daily query compares full refusals with what All Subscribers says and writes to a control table in Overwrite mode.

More in this series
Pierre Frin
Grokium founder · CRM consultant · Adobe Campaign Classic and Salesforce Marketing Cloud Email Specialist certified

Would your preference centre hold up against a complaint?

I can review your preference pages, your unsubscribe calls and your consent records, then hand you a written diagnosis with the fixes to apply.

Tell me about your context →