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/projectsneeds 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:
- Does the contact exist?
POST /api/contacts/eventreturns404for an unknown email; it does not create contacts. - Was the tag actually added? The response distinguishes
addedfromnoop— anoopmeans the tag was already there, and re-adding an existing tag does not re-trigger. - 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.