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.
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.
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
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.
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 proof | Field | Example |
|---|---|---|
| The person | ClientId | 100245 |
| The purpose and the choice | Finalite, Valeur | partenaires, true |
| The moment | DateEvenement | Server date and time, with the time zone documented |
| The point of collection | Source | centre_preferences, creation_compte, magasin |
| The wording shown | VersionTexte | PART-2026-03, pointing to the wording table |
| The send context | JobId | Identifier of the send that brought the customer to the page |
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.
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.
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.
| Mechanism | Scope | Note |
|---|---|---|
| Preference in a data extension | None, as long as sends do not read it | Apply it with an exclusion script or a targeting query |
| Unsubscribe from a publication list | Sends attached to that list | This is what the standard subscription centre does |
List-Unsubscribe header | Same as the subscription centre, at list level | Handled by Salesforce, as a POST, neither disabled nor changed |
LogUnsubEvent without send context | Global unsubscribe for the subscriber | Visible in All Subscribers |
LogUnsubEvent with the send context | The list of that send, the event is attached to the job | The unsubscribe appears in the send tracking |
| Business unit unsubscribe | One business unit of an Enterprise 2.0 account | Visible 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.
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)
]%%
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 →