The newsletter, with double opt-in
The backend keeps the list of people who want your newsletter.
The backend keeps the list of people who want your newsletter. Nobody joins by typing an email: they join when the owner of that address clicks the link in the confirmation email.
Campaigns are not sent from here: the list is synced with an email service (Brevo, Mailchimp or Resend) and the campaigns are written there. Read "Connecting an email service".
Why two steps
No law requires double opt-in, but the GDPR (Article 7.1) requires you to be able to prove that the owner of the address consented. A form that just stores the email proves nothing: anyone can type someone else's.
So each subscription stores:
- the exact text that was shown next to the box, and its version;
- the date, the IP of whoever asked, the IP of the storefront that passed the request on, and the browser;
- the date on which the owner of the email clicked the confirmation link.
Without the consent text and its version, the subscription is refused.
How someone subscribes
The theme calls newsletter.subscribe(email, consent) from the
commerce contract, with the text shown and
its version, the language and, if it has them, where the form was, the IP and
the browser.
- The subscription is stored as pending and an email goes out with a link.
- The link is
/boletin/confirmar?token=…on the address inSTOREFRONT_URL(orPCC_TIENDA_URL). It is a language-free entry route: the storefront sends it on to the visitor's language. Without that variable the link has no domain, so set it. - The link works once and for seven days. When clicked, the theme calls
newsletter.confirm(token)and the subscription becomes confirmed. If it has expired or was already used,confirmreturnsfalse.
The email goes out in the language the subscription came with: English or Spanish. Any other language, or none, gets English.
What the form never gives away
The answer is always the same — "pending" — whether the address was new, already pending or already confirmed. If it said "you are already subscribed", the form would tell anyone who is on your list.
And asking again does not become a way to flood someone's inbox: a pending address does not get another email until ten minutes after the last one. An address that is already confirmed gets nothing.
Unsubscribing
Every subscription has its own unsubscribe token. Unsubscribing asks for nothing
but that link — it has to be as easy as subscribing (GDPR, Article 7.3). The
theme calls newsletter.unsubscribe(token), which always answers yes, even if
the person had already left.
Someone who unsubscribes and signs up again starts from zero: new pending state, new confirmation email. The old consent does not count for the new subscription.
Getting the list
The panel has a Newsletter screen (Customers → Newsletter) with the three counts, the list, the proof of consent for each person, an unsubscribe button and a CSV export.
From the management API, with any team account:
GET /gestion/boletin # only the confirmed ones, with their unsubscribe token
GET /gestion/boletin/suscritos # the whole list with the proof of consent
GET /gestion/boletin/csv # the same, as CSV/gestion/boletin returns only the confirmed ones, in the order they
confirmed. Pending and unsubscribed addresses never come out.
Connecting an email service
Campaigns are not sent from here. What the store does is keep its list and the email service's list saying the same thing, in both directions. That sync is where this always breaks, and it is the part with legal consequences: someone who unsubscribes on the service and keeps receiving, or the other way round.
The screen is Newsletter → Connect a service (/boletin/conector), and only
an Administrator can open it: these are a third party's keys. They are
stored encrypted with the server's secret (PCC_CLAVE_CIFRADO, or JWT_SECRET
if it is missing); without either, saving them is refused with a 503 rather than
encrypted with something guessable.
Which services, and why
| Service | Data lives in | Unsubscribe notices | Good for |
|---|---|---|---|
| Brevo | European Union | unsubscribed, hardBounce, spam | The default choice in Europe: French company, no transfer framework needed, and the only one that tells the three things apart |
| Mailchimp | United States | unsubscribe, cleaned (hard bounce) | Plugging into an audience the owner already has |
| Resend | United States | contact.updated, email.complained, email.bounced, suppression.added | Stores already sending their email with it |
There is no common standard for this, and there is no point waiting for one:
Mailchimp models a rich status enum, Brevo a blocklist boolean and Resend a
global boolean. The only real standard nearby is
RFC 8058 — the one-click
List-Unsubscribe header — and it is about sending email, not about syncing
lists. What the three do share is exactly the size of the connector's contract:
the email as the identifier, a state that boils down to "receives / does not
receive", and some untyped attributes.
Only one service can be connected at a time. Two services on the same list means two campaigns to the same inbox and two places to half-unsubscribe. Switching service re-sends everything, because the new service's list knows nothing about what was synced with the old one.
What goes each way
- Confirmed subscription here → subscribed there. Never before confirming: what makes the list worth anything, and legal, is confirmed consent.
- Unsubscribe here → unsubscribed there. By link, from the panel, it does not matter.
- Unsubscribe there → unsubscribed here. By notice, or by the sweep that runs every 30 minutes.
- A hard bounce or a spam complaint is an unsubscribe too, and it is stored
as such (
motivo_baja:rebote,queja), because carrying on writing to a mailbox that does not exist — or to someone who marked you as spam — sinks the domain's reputation and with it the order emails.
A subscription made on the service is not brought in. Consent lives here, and an address without its proof does not belong in this list.
And a subscription never resurrects someone who unsubscribed on the service. Mailchimp flatly forbids it (400, "Member In Compliance State"); in Brevo and Resend it would be possible, but it would mean writing again to someone who said no from the other side.
What happens when the service is down
An unsubscribe cannot be lost, so nothing is sent with a bare fetch inside
the storefront's request. There are two paths, and both are needed:
- The queue. Confirming or unsubscribing writes a
boletin.altaorboletin.bajaevent into the same database, right after the change. The dispatcher takes it to the service and retries with growing waits. If it gives up after six attempts, the event is not deleted: it is set aside, and the panel can retry it. - The sweep (
repasar-boletin, every 30 minutes). Each address stores what the service last confirmed it knows (sincronizado_estado) next to what it should know (estado). The difference is the pending work, and the sweep fixes it — whether it comes from a queue that gave up, a service that was down all afternoon, or a change of service.
The sweep asks first and pushes afterwards, and the order matters: pushing first could put someone back into the campaigns right after they unsubscribed on the service, before we had brought that unsubscribe in.
The notice address
The panel shows the address to paste into the service, and it carries its own key. That key is the only protection a service that does not sign its notices has (Brevo does not sign them), and the first door for the ones that do. Treat it like a password.
Where the service signs, the signature is verified as well: Mailchimp with
X-Mailchimp-Signature (HMAC-SHA256 over timestamp.body, rejected if it is
more than five minutes old) and Resend through Svix (svix-id,
svix-timestamp, svix-signature, with the whsec_ secret). A notice that
cannot be verified unsubscribes nobody.
The address is built from PCC_BACKEND_URL; set PCC_BOLETIN_WEBHOOK_URL only
if the notices have to arrive somewhere else.
What is still missing
- Sending. There are no campaigns, templates or a send button here: you write the newsletter in the email service and send it from there. Without a connected service, export the list and use your own tool.
- The unsubscribe link in each email. If you send from a connected service,
use its own unsubscribe link and the notice will come back here. If you send
by hand, build the link with each person's token, for example
/boletin/baja?token=…on your storefront. - The factory themes do not connect it. The newsletter section of
baseposts to an externalhttps://address you set in its settings (without one, the section is not drawn) and, in the demo, only shows a message and stores nothing;mascotasandcinematograficodraw the form without sending it anywhere;dulce-obradorsays "You're subscribed!" without storing anything. None of them has the confirm or unsubscribe page. A theme that wants a real newsletter has to callsubscribe,confirmandunsubscribe.
See also
Moving in from Shopify or WooCommerce
Bringing across your catalogue, your customers and your order history from your current shop, without losing the ranking you already had on Google.
Paying authors and sellers
Before moving other people's money, check it with an adviser. This guide explains what the system does; not what the law requires of you.