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.

I have forgotten my dashboard password

There is no emailed reset link: a new instance may not be able to send email until somebody can sign in and connect AWS, which is when a password gets forgotten. And a deployment made by pushing to Git gives you no shell to run a command in. So the way back in is something you already hold.

With your recovery code. When you created the account, SESKit showed a code like K7QD-4M2X-9PHB-TWE6, once. On the sign-in page choose Forgot your password?, then give your email, that code, and a new password. Capitals and hyphens are optional.

  • The code works once. Using it signs you in and shows the next one, so save that too.
  • It signs the account out everywhere, which matters if you are recovering because somebody else may have had access.
  • The new password must be at least 12 characters, the same as at sign-up.
  • After 10 wrong attempts for an email, recovery pauses for 15 minutes. Signing in is not affected. The number and window are LOGIN_MAX_ATTEMPTS and LOGIN_ATTEMPT_WINDOW_SECONDS.

If you have lost the code, or the account never had one (accounts created before recovery codes existed have none), use the recovery key. Whoever runs the deployment sets RECOVERY_KEY in its environment, redeploys, and gives that value in the same box in place of a code. Make it at least 24 characters:

python -c "import secrets; print(secrets.token_urlsafe(32))"

An instance refuses to start if RECOVERY_KEY is set but shorter than that, rather than quietly ignoring it. The key is not used up by recovering, so remove it from the environment once you are back in, unless you want it kept as a standing way in. See configuration.

Make sure you have a code. Once signed in, Settings says whether the account has one and makes a new one, after asking for your password again. Do that now if you have an older account.

With a shell, there is also a command, for instance on a machine you run yourself:

docker compose exec api seskit reset-password

It asks for the email and the new password at a hidden prompt, so the password stays out of your shell history, and signs the account out everywhere. It does not reactivate a deactivated account. Without Docker, run seskit reset-password from the environment the API runs in, so it reads the same .env.

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.