Pharos
Guides.md

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.

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.

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/sync will not resubscribe a suppressed address no matter what the payload says. It reports how many it refused as protectedSuppressions.
  • POST /api/contacts/import leaves explicit opt-outs alone and reports them as skippedUnsubscribed.

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.

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 template
  • mailingAddress set on the project
  • Delivery webhooks configured, so bounces and complaints actually arrive — see Sending & deliverability
  • Bulk syncs run with triggerAutomations: false for 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