All endpoints accept POST with Content-Type: application/json. Authentication is via authToken in the request body, stripped before storage.
The name field on every User request body must be sent empty (""). User nicknames never travel as a plain field — they're carried only inside the encrypted payloads of messages, so the server cannot index or read them.
Every object emits all of its declared fields on the wire, in declaration order, with a leading "object" discriminator — including null strings and 0 longs. The shapes below show the actual wire format; timestamp on outgoing client bodies is filled in by the server on receipt. Clients should tolerate (ignore) any unknown fields a future version may add.
Hand this spec to a frontier LLM and one-shot the backend — the collapsible block below is the exact prompt we used; the blog post covers the two runs (Gemini and Claude). Python is just one example stack; the wire format is language-agnostic. Open-source reference client: Secchats-tech/ChatJavaUI.
You are an expert Python backend engineer. Build a complete, runnable
HTTP API server implementing the SecChats JSON API specified at
https://secchats.com/api.html.
The spec is the contract. Read it. The summary in this prompt is a
fallback when you don't have web access — implement the spec's shapes
verbatim, not a paraphrase.
Stack (default — swap only with a written reason)
FastAPI + Pydantic + stdlib sqlite3 + uvicorn. No ORM. Async endpoints
throughout because /longpoll requires a real wait-notify primitive, not
a polling loop.
Endpoints (all POST, all Content-Type: application/json)
/register — Create a user → return userId + authToken. No
/register2 — Create a group → return groupId. Yes
/receive — Notifications since lastMessageTimestamp. Yes
/connect — Friend request (DH public key) to target user. Yes
/connect2 — Reply to a friend request. Yes
/postmsg — Send an encrypted direct message. Yes
/groupinv — Deliver an encrypted group key to a user. Yes
/postgroup — Send an encrypted group message. Yes
/pollgroup — Group messages since timestamp. Yes
/pollgroups — Multi-group variant of /pollgroup. Yes
/longpoll — Block up to 25s waiting for any of N groups to
receive a message for the caller. Plain-text
response. Yes
Canonical body shapes (every always-emitted field, in declaration order)
The reference Java serializer (ChatSerializer) emits every declared
field of every model by reflection, in declaration order, with a
leading "object" discriminator. Mirror that exactly.
// /register response
{ "object":"User", "userId":"<hex>", "name":"",
"authToken":"<token>", "lastMessageTimestamp":0 }
// /register2 response (newly created Group)
{ "object":"Group", "groupId":"<id>", "name":null,
"algorithm":null, "privateKey":null,
"lastMessageTimestamp":0 }
// /receive request body
{ "object":"User", "userId":"<id>", "name":"",
"authToken":"<token>",
"lastMessageTimestamp":1712345678000 }
// /receive response (Notifications)
{ "object":"Notifications",
"friendRequests":[ { "object":"FriendRequest",
"fromUserId":"<id>", "authToken":null,
"toUserId":"<id>", "timestamp":1712345678000,
"protocol":"DH_AND_AES", "cryptoData":"<base64>" } ],
"friendResponses":[ ... ],
"groupInvites":[ ... ],
"messages":[ { "object":"SendMessage", "toId":"<id>",
"authToken":null, "fromUserId":"<id>",
"encContent":"<base64>",
"timestamp":1712345678000 } ] }
// /connect request
{ "object":"FriendRequest", "fromUserId":"<id>",
"authToken":"<token>", "toUserId":"<target>",
"timestamp":0, "protocol":"DH_AND_AES",
"cryptoData":"<base64-dh-public-key>" }
// /postmsg request (toId = recipient userId)
// /postgroup request (toId = groupId — same shape)
{ "object":"SendMessage", "toId":"<target>",
"authToken":"<token>", "fromUserId":"<id>",
"encContent":"<base64>", "timestamp":0 }
// /longpoll response: plain text — group id that fired or "".
Wire-format notes (the spec page elides some always-present fields)
- Every model emits every declared field in declaration order —
null and 0 included. Mirror that exactly on responses.
- Strip authToken (set to null) on anything echoed back from the
notification queue, but keep the field in the JSON.
- Clients sometimes omit timestamp:0 and lastMessageTimestamp:0 on
send. Accept gracefully on parse, emit always on serialise. With
Pydantic: model_config = ConfigDict(extra='ignore') plus
Field(default=0) on every long.
- name is "" for users and null (or "" — be tolerant) for newly
created groups. The server never persists or inspects it.
Storage (zero-trust — server stores the absolute minimum)
CREATE TABLE principals (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('user','group')),
auth_token_hash BLOB,
created_at INTEGER NOT NULL
);
CREATE TABLE notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
to_principal TEXT NOT NULL REFERENCES principals(id),
kind TEXT NOT NULL CHECK (kind IN
('FriendRequest','FriendResponse',
'GroupInvite','SendMessage')),
from_user TEXT,
group_id TEXT,
protocol TEXT,
algorithm TEXT,
crypto_data BLOB,
enc_content BLOB,
ts_ms INTEGER NOT NULL
);
CREATE INDEX idx_notif_to_ts
ON notifications(to_principal, ts_ms);
Hard invariants — fail the build mentally if any are violated:
- authToken is never persisted. Store HMAC_SHA256(SECCHATS_HMAC_SECRET,
token) and constant-time compare via hmac.compare_digest.
- name field is sent and stored as "". Never index, log, or echo
display names. They live only inside encContent.
- cryptoData / encContent are opaque blobs — store and return
untouched.
- Per-recipient queues: friend-request / friend-response /
group-invite / direct-message rows keyed on the target user. Group
messages keyed on the group.
- No plain auth tokens or message content in logs.
Behavioural invariants
- All non-/register endpoints validate userId + authToken against
principals.auth_token_hash. Mismatch ⇒ HTTP 429 plain-text body
"Rate limited." (per the spec's error table).
- /connect and /groupinv ⇒ 404 if target user / group doesn't exist.
- Polling endpoints return only rows with ts_ms > lastMessageTimestamp.
- When emitting any queued item, set authToken to null before
serialise (mirrors the reference server's clearAuthToken()).
- /longpoll implementation: a dict[str, list[asyncio.Event]] keyed by
user id. On any insert into notifications for a user, set + drain
that user's events. Endpoint awaits with
asyncio.wait_for(..., timeout=25). Return "" on TimeoutError. No
while True: sleep.
- Per-IP token-bucket rate limit on every endpoint: 30 requests / 5
seconds. Excess ⇒ 429 plain text.
- Timestamps are int(time.time() * 1000) everywhere.
Deliverables (single project directory)
server/
app.py # FastAPI app, all endpoints, Pydantic models
store.py # SQLite helpers + async event registry
schema.sql # the DDL above
test_api.py # pytest, one happy-path per endpoint
requirements.txt
README.md # venv setup, run, test
Must run with:
pip install -r requirements.txt
SECCHATS_HMAC_SECRET=test uvicorn app:app --port 8080
pytest -q
What to avoid
- ORMs.
- Endpoints not in the spec.
- Polling loops for /longpoll.
- Storing or logging cleartext tokens, display names, or message
bodies.
Definition of done
- pytest -q is green.
- Round-trip /register x 2 → A /connects B → B /receives the friend
request with the exact wire shape.
- A second user /longpolling on a shared group is woken within ms
when the first user /postgroups to it.
- sqlite3 db.sqlite '.dump' shows no plaintext tokens, no display
names, and no plaintext message content.
Build it. Output the full file tree with file contents inline.
Register a new user. No request body required. Returns a generated user ID and an auth token.
{
"object": "User",
"userId": "<generated-hex-id>",
"name": "",
"authToken": "<generated-token>",
"lastMessageTimestamp": 0
}
Generate a new group ID. Caller must authenticate.
{
"object": "User",
"userId": "<userId>",
"name": "",
"authToken": "<authToken>",
"lastMessageTimestamp": 0
}
{
"object": "Group",
"groupId": "<generated-group-id>",
"name": null,
"algorithm": null,
"privateKey": null,
"lastMessageTimestamp": 0
}
Poll for pending notifications (friend requests, friend responses, group invites, direct messages) since a given timestamp.
{
"object": "User",
"userId": "<userId>",
"name": "",
"authToken": "<authToken>",
"lastMessageTimestamp": 1712345678000
}
{
"object": "Notifications",
"friendRequests": [
{
"object": "FriendRequest",
"fromUserId": "<id>",
"authToken": null,
"toUserId": "<id>",
"timestamp": 1712345678000,
"protocol": "DH_AND_AES",
"cryptoData": "<base64-key-exchange>"
}
],
"friendResponses": [
{
"object": "FriendResponse",
"fromUserId": "<id>",
"authToken": null,
"toUserId": "<id>",
"timestamp": 1712345678000,
"cryptoData": "<base64-key-exchange>"
}
],
"groupInvites": [
{
"object": "GroupInvite",
"fromUserId": "<id>",
"authToken": null,
"toUserId": "<id>",
"groupId": "<groupId>",
"timestamp": 1712345678000,
"algorithm": "AES_256",
"cryptoData": "<encrypted-group-key>"
}
],
"messages": [
{
"object": "SendMessage",
"toId": "<id>",
"authToken": null,
"fromUserId": "<id>",
"encContent": "<encrypted-message>",
"timestamp": 1712345678000
}
]
}
Send a friend request. Initiates the key exchange by delivering the caller's Diffie-Hellman public key to the target user's notification queue.
{
"object": "FriendRequest",
"fromUserId": "<userId>",
"authToken": "<authToken>",
"toUserId": "<targetUserId>",
"timestamp": 0,
"protocol": "DH_AND_AES",
"cryptoData": "<base64-dh-public-key>"
}
Reply to a friend request. Completes the key exchange by delivering the responder's Diffie-Hellman public key to the original sender.
{
"object": "FriendResponse",
"fromUserId": "<userId>",
"authToken": "<authToken>",
"toUserId": "<targetUserId>",
"timestamp": 0,
"cryptoData": "<base64-dh-public-key>"
}
Send an encrypted direct message to another user.
{
"object": "SendMessage",
"toId": "<targetUserId>",
"authToken": "<authToken>",
"fromUserId": "<userId>",
"encContent": "<encrypted-message-payload>",
"timestamp": 0
}
Invite a user to a group. Delivers the AES-256 encrypted group key to the recipient's notification queue. Both the target user and the group must already exist.
{
"object": "GroupInvite",
"fromUserId": "<userId>",
"authToken": "<authToken>",
"toUserId": "<targetUserId>",
"groupId": "<groupId>",
"timestamp": 0,
"algorithm": "AES_256",
"cryptoData": "<encrypted-group-key>"
}
Send an encrypted message to a group. Uses the same schema as /postmsg with toId set to the group ID.
{
"object": "SendMessage",
"toId": "<groupId>",
"authToken": "<authToken>",
"fromUserId": "<userId>",
"encContent": "<encrypted-message-payload>",
"timestamp": 0
}
Poll for new messages in a single group since a given timestamp.
{
"object": "PollGroup",
"userId": "<userId>",
"authToken": "<authToken>",
"groupId": "<groupId>",
"lastMessageTimestamp": 1712345678000
}
{
"object": "GroupNotifications",
"messages": [
{
"object": "SendMessage",
"toId": "<groupId>",
"authToken": null,
"fromUserId": "<id>",
"encContent": "<encrypted-message>",
"timestamp": 1712345678000
}
]
}
Poll for new messages across multiple groups at once since a given timestamp.
{
"object": "PollGroups",
"userId": "<userId>",
"authToken": "<authToken>",
"groupIds": ["<groupId1>", "<groupId2>"],
"lastMessageTimestamp": 1712345678000
}
Same GroupNotifications schema as /pollgroup.
Long-poll across multiple groups. Blocks server-side until a new message arrives for the user in any of the listed groups, then returns immediately.
{
"object": "PollGroups",
"userId": "<userId>",
"authToken": "<authToken>",
"groupIds": ["<groupId1>", "<groupId2>"],
"lastMessageTimestamp": 1712345678000
}
Plain text: the group ID that received a new message, or empty string if the wait timed out.
| Status | Meaning |
|---|---|
| 400 | Malformed or missing required fields |
| 404 | Target user or group does not exist |
| 429 | Rate limited — back off and retry |
| 500 | Internal server error (plain text exception message) |