Skip to content

Troubleshooting

Start here

uv run python scripts/doctor.py
docker compose exec api python -m seskit_api.doctor   # against a container

Seven checks, in the order a first hour actually fails: the secret key, the database URL, whether Postgres answers and is at the schema this code expects, whether Redis answers, which path a send would take today, and whether anything is draining the queue. Each failure names the one thing to change.

It is deliberately not the same as /readyz. That probe answers "should traffic come here"; this one answers "is this set up to do what I want". An instance with no SMTP and no AWS connection is ready, correct, and cannot send a single message.

Exit code 0 or 1, so it works in a gate as well as by hand.

The rest

Ordered by how often each one turns out to be the answer.

Mail is stuck at queued

The worker is not running. Sending is queued, so the API accepts a message and something else has to send it.

docker compose ps               # is the worker up?
docker compose logs -f worker

If the worker is running but nothing moves, check Redis — the queue lives there, and a worker that cannot reach it says so once on startup and then sits quietly.

A send fails with an unverified sender

The from address is not covered by a verified identity, and the project has AWS connected. SESKit refuses rather than falling back to Mailpit, because falling back would report success while the message reached nobody.

Add the address or its domain on the Domains page — see verify a sender.

A send fails with a rejected recipient

Almost always the SES sandbox: inside it you can only send to verified addresses. The AWS page says whether you are still in it.

No delivery events are arriving

In order of likelihood:

  1. Event reporting was never set up. It is a separate button on the AWS page, and it needs the second IAM policy.
  2. The messages predate the setup. SES only reports on mail sent through a configuration set, so older messages have no history and never will.
  3. The worker is not polling. Check its logs for SQS errors — usually a missing sqs:ReceiveMessage permission.
  4. You are on HTTPS ingestion and SNS cannot reach you. Confirm PUBLIC_BASE_URL is correct, publicly resolvable and holds a valid certificate. A subscription stuck at PendingConfirmation means SNS never got an answer.

Webhooks are not being delivered

The delivery history on the Webhooks page records every attempt with its status and response.

What you see Means
4xx responses Your endpoint refused. Not retried — SESKit reads a 4xx as a decision
Timeouts Your endpoint is too slow. Answer immediately, process afterwards
"SESKit stopped sending" Ten consecutive failures disabled it. Fix the endpoint, then re-enable
Nothing at all There are no events to forward. See the section above

A destination refused at registration is being blocked as SSRF — loopback and private addresses are rejected outside local development.

Signature verification fails at my endpoint

Nearly always one of three, in order:

  1. You are verifying a re-serialised body. Parse after verifying, and use the raw bytes.
  2. You left the timestamp out of the signed string. It is "{timestamp}.{body}", not the body alone.
  3. The secret belongs to a different endpoint. Each one has its own.

See verifying the signature.

The dashboard shows dashes instead of rates

That is correct. means the denominator is zero: nothing has been sent yet in the selected range, and 0% would assert something untrue.

If open and click read "Not tracked", tracking is off for that project, which is the default. See reading your metrics.

It will not start

Symptom Cause
Refuses to boot, complains about SECRET_KEY Still set to the example value
Cannot reach the database Check DATABASE_URL; on a laptop check the port really is 55432
Connects, but the data is wrong Something else is bound to that port. This is why the ports are unusual — see install
Migrations fail Compare uv run alembic current against alembic history

Reading the logs

Logs are structured. Every send, event and webhook delivery is recorded with ids, and never with message bodies or recipients — those stay in the database rather than being scattered through log files.

docker compose logs -f api worker

A page loads but a button does nothing

Open the browser console. SESKit sends a strict Content-Security-Policy, and a script that is not served from this origin, or an inline script without the request's nonce, is refused rather than run — silently, unless you are looking.

If you have added an inline <script> to a template, give it nonce="{{ csp_nonce }}". The test suite fails on any that lack it, so this should only ever bite during an edit.

A send is refused with attachment_too_large before it is read

The request body was over MAX_REQUEST_BYTES, which is checked before anything reads it. That is a different limit from EMAIL_MAX_MESSAGE_BYTES, which is checked against the assembled message afterwards; the first protects the server, the second protects the send.

By default the request cap is half again the message ceiling, so hitting it means a body far larger than any message it could have carried.