# 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 `429`s 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](/docs/sending-providers).

### `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](/docs/sending-providers#amazon-ses).

### 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](/docs/compliance).

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](/docs/campaigns#1-pick-a-rendering-path).

### 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](/docs/api-templates#tokens), 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](/docs/compliance#the-public-subscribe-form-is-double-opt-in).

### 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.
