Pharos
Guides.md

Troubleshooting

The failures that actually happen, and what each one means.

Authentication and scopes

401 Unauthorized

The credential was rejected. In order of likelihood:

  • The key is expired or revoked. Both fail exactly like a wrong key.
  • It is a project key addressing another project. A phk_… is pinned; the project in your request must be its own.
  • The prefix is wrong for the endpoint. POST /api/v1/projects needs an organization key (pha_…) — a project key cannot create projects.

A presented-but-invalid key never falls through to the session cookie, so a bad Authorization header fails even in a browser where you are logged in. That is deliberate.

403 Missing required scope: …

The key is valid but under-scoped, and there are two very different reasons.

The scope is campaigns:send or events:send. This is the design working. Agent keys omit the send scopes on purpose, and no amount of re-issuing changes that — issue a key with the send scope, from a human, for the server that genuinely needs to deliver.

Any other scope. The key is probably older than the feature. Scopes are fixed at creation, so re-issue it with what you need.

Where the message names two scopes joined by +, the endpoint requires both. GET /api/sends demands campaigns:read+contacts:read because it crosses contact identities with campaign activity.

404 Project not found on a project you know exists

Your key cannot reach it. An organization key addressing a project in another organization gets 404, not 403 — deliberately indistinguishable from a project that does not exist, so a key cannot be used to probe for other tenants' project names.

Call GET /api/v1/projects to see what the key can actually reach.

429 Rate limit exceeded

240 requests per minute per key, fixed windows. Honour the Retry-After header. If you are hitting this from an agent, something is looping — the limit exists to turn a runaway loop into 429s rather than an unbounded export.

409 on project creation

The slug is taken. Slugs are globally unique, not per-organization, so a collision can be with a workspace you cannot see. Pass an explicit slug.

Email that did not arrive

The response said "mock": true

No sending credential is configured, so nothing was sent. Connect a provider — see Sending & deliverability.

Project … has no email account configured

The project has no sending account attached. Pharos refuses rather than falling back to somebody else's credential. Attach one in Settings → Sending.

Every send sits at sent and never becomes delivered

Mail is going out fine; you have no delivery feedback. The provider webhook is not configured.

  • Resend — add the endpoint URL as a webhook and paste the whsec_… signing secret back into the account. Without the secret, events are rejected and delivery state never advances.
  • Your own SES account — delivery tracking is not available yet, so this is expected rather than a misconfiguration. See Sending & deliverability.

Bring-your-own SES rejects everything

If SES is refusing sends outright, check whether something is naming a configuration set that does not exist in your AWS account. Pharos does not name one on a tenant credential for exactly this reason. pharos_tracking belongs to the platform account and is not assumed to exist in yours.

warning: "No recipients found"

The campaign completed with recipientCount: 0. The recipientMode matched nobody — a list slug that does not exist, a tag nobody carries, or a list whose members are all unsubscribed. Preview first; the count is the whole point of the preview.

One specific person never receives anything

They are probably suppressed. A permanent bounce or a spam complaint unsubscribes a contact across the entire project, and no sync or import will put them back — see Unsubscribes & suppression.

Check GET /api/sends?email=… for a bounced or complained row.

status: "failed" on an operational event

Operational mail is delivered synchronously, so 502 with status: "failed" means it genuinely did not go out. Surface that to whoever is waiting rather than showing them a success screen. Product mail is queued and retried instead, so a 2xx there means accepted, not delivered.

Content problems

The email shipped with {{contact.firstName}} visible

Tokens only resolve in stored template HTML. Copy passed as content against a built-in React template is rendered literally. Create a template with POST /api/email-templates and send its HTML — see Sending a campaign.

A token resolved to nothing

Unknown tokens resolve to an empty string rather than erroring, so a typo is silent. Check the spelling against the token table, and give tokens that may be missing a fallback: {{contact.firstName|there}}.

Branding is missing from a template

Branding is baked into a template's HTML when the starter is seeded — it is not a runtime token. A template assembled by hand will not acquire your logo and colours by itself.

Automations

An automation never fires

Triggers are tag-based, so work backwards:

  1. Does the contact exist? POST /api/contacts/event returns 404 for an unknown email; it does not create contacts.
  2. Was the tag actually added? The response distinguishes added from noop — a noop means the tag was already there, and re-adding an existing tag does not re-trigger.
  3. Is the automation enabled, and does it have steps?

A run stopped partway

GET /api/automations/{id} returns run counts by status and the ten most recent runs with their errors. A step can also skip itself deliberately via skipIfTag — that is a completed run, not a failure.

Someone subscribed on the form but never appears as a subscriber

Their subscription is pending — the public form is double opt-in, and the confirmation link has not been followed. Check that the project can actually send, because if the confirmation email never went out, nobody can confirm. See Unsubscribes & suppression.

A backfill mailed everyone a welcome sequence

triggerAutomations was true on a historical import. Set it to false for anything that is not a real-time user action.

Contacts

A sync reports protectedSuppressions

It refused to resubscribe hard-bounced or complained addresses. This is correct, and it is the guardrail that stops a nightly reconcile job from rebuilding a list of people who asked you to stop.

An import reports skippedUnsubscribed

Same idea for explicit opt-outs. A spreadsheet does not overrule a person.

Tags disappeared after an update

PATCH /api/contacts/{id} replaces the whole tag list. Read the current tags and send them back along with the new one.

Note also that adding a tag can start an automation, so tags are actions rather than annotations.

Still stuck

Every key-authenticated request writes an audit row — which key, which project, method, path, the scope demanded, and the outcome. If a request is failing and the reason is not obvious, that log distinguishes "never arrived", "wrong scope", "rate limited" and "wrong project" without guessing.