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.
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:
- Event reporting was never set up. It is a separate button on the AWS page, and it needs the second IAM policy.
- The messages predate the setup. SES only reports on mail sent through a configuration set, so older messages have no history and never will.
- The worker is not polling. Check its logs for SQS errors — usually a
missing
sqs:ReceiveMessagepermission. - You are on HTTPS ingestion and SNS cannot reach you. Confirm
PUBLIC_BASE_URLis correct, publicly resolvable and holds a valid certificate. A subscription stuck atPendingConfirmationmeans 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:
- You are verifying a re-serialised body. Parse after verifying, and use the raw bytes.
- You left the timestamp out of the signed string. It is
"{timestamp}.{body}", not the body alone. - The secret belongs to a different endpoint. Each one has its own.
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.
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.