Unsubscribes & suppression
Consent is the part of a marketing system that has to be right the first time. A lost open is a metric; a mail to someone who opted out is a complaint, and enough complaints cost you the ability to send at all.
This page is what Pharos guarantees, and what it needs from you.
Consent lives on the subscription
A contact does not have a global subscribed flag. Consent attaches to the pair
of contact and list, so someone can leave your newsletter and keep getting
product updates. Each subscription carries its status — subscribed,
unsubscribed, pending — plus when it changed and, importantly, how:
| Source | Meaning |
|---|---|
form |
Used the unsubscribe page |
one_click |
Tapped the unsubscribe button in their mail client |
hard_bounce |
Address does not exist — suppressed |
complaint |
Marked the mail as spam — suppressed |
admin |
Changed by a human in the dashboard |
Keeping the reason is what makes the difference between "they left" and "we were told to stop", and only the second is irreversible.
The public subscribe form is double opt-in
POST /api/subscribe is unauthenticated by necessity — it backs a signup box
on your own site — so it cannot know that whoever typed an address owns it.
Everything about how it behaves follows from that.
A submission creates a subscription with status pending and mails a
signed confirmation link. Recipient resolution requires subscribed, so a
pending address receives nothing at all until somebody follows that link. Only
then does the status become subscribed, and only then do new_subscriber
automations fire.
Without this, the endpoint is a weapon: anyone could subscribe a stranger's address to your project, and the resulting complaints would land on your sending reputation. Since Pharos never pools sending, that reputation is yours alone to lose.
Two further rules on the same endpoint:
- A suppressed address is never resurrected. A hard bounce or a complaint outranks a form submission exactly as it outranks a sync.
- The response never says an address is suppressed. A refusal looks identical to an ordinary pending signup, because otherwise the form becomes a way for a stranger to find out who complained.
Submissions are also rate limited per client address.
Every list email carries a one-click unsubscribe
Mail sent to a list gets both headers Gmail, Apple Mail and Outlook look for:
List-Unsubscribe: <https://pharosbase.com/unsubscribe?…>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
The mail client renders its own unsubscribe control and POSTs to that URL
when it is used. No page load, no confirmation step, no chance for the reader
to give up and hit "spam" instead — which is the entire point, and why bulk
senders are now required to support it.
The same URL is what {{unsubscribeUrl}} resolves to inside your template, so
the in-body link and the client's own button lead to the same place.
Put {{unsubscribeUrl}} in every template that goes to a list. It is the
one token whose absence is not a cosmetic problem.
Unsubscribe links are signed and permanent
The link carries an HMAC of the recipient's address and the project, so it cannot be forged or edited to unsubscribe somebody else. It also does not expire — a two-year-old email in an archive still unsubscribes correctly, which is the behaviour you want, because the alternative is a dead link and a spam report.
The signing key is a deployment secret. Rotating it invalidates every unsubscribe link in every message already delivered, and there is no way to re-sign mail that has left. Treat it as permanent once you have sent.
Suppressions cannot be undone by a sync
A permanent bounce or a spam complaint unsubscribes the contact across the entire project, on any provider, the moment the notification arrives. Those two are suppressions, and they are protected against every bulk path that could otherwise resurrect them:
POST /api/v1/contacts/syncwill not resubscribe a suppressed address no matter what the payload says. It reports how many it refused asprotectedSuppressions.POST /api/contacts/importleaves explicit opt-outs alone and reports them asskippedUnsubscribed.
This matters more than it looks. The classic way to destroy a sending reputation is a nightly reconcile job that faithfully re-uploads "all active users" from the product database — a database that has no idea anyone complained. Pharos treats that job's opinion as outranked by the recipient's.
Re-subscribing a suppressed contact is possible, but it takes a deliberate,
per-contact act through
PATCH /api/contacts/{id}/subscriptions/{listId}
— never a bulk import. If someone genuinely wants back in, have them sign up
again.
Your mailing address goes in the footer
Set mailingAddress on the project. It is rendered into the footer of mail
Pharos sends, and a physical postal address is required by bulk-email rules in
most jurisdictions — CAN-SPAM in the US among them.
It is a project field rather than a template field on purpose: a per-template address is a per-template way to forget it.
Operational mail is exempt, deliberately
Transactional events sent with notificationCategory: "operational" — a
contact-form notification to your own team inbox, an internal alert — skip
contact handling entirely. No contact record, no subscription check, no
unsubscribe header.
That is not an oversight. Operational mail goes to you, not to a subscriber,
and a stray unsubscribe must never be able to mute your own alerting. Mail to
an actual user is notificationCategory: "product", which honours subscription
state and carries the unsubscribe header like anything else.
Getting this wrong in the other direction — sending marketing mail as
operational to dodge the unsubscribe requirement — is exactly the abuse the
category distinction is there to make visible. Don't.
A short checklist
{{unsubscribeUrl}}in every list-bound templatemailingAddressset on the project- Delivery webhooks configured, so bounces and complaints actually arrive — see Sending & deliverability
- Bulk syncs run with
triggerAutomations: falsefor historical data, so a backfill does not mail everyone a welcome sequence - Nothing in your stack treats "user exists" as "user consented"
- Your project can actually send, or the confirmation email never arrives and
every signup stays inert at
pending