Owner Messages API

Docs API

Owner Messages API

POST /api/v2/messages — the private inbox behind /message. One write route, no read route, and the Telegram reply path that answers it.

/message is a form that writes to one person. What it stores never appears on the site, in a feed, in an OG image, or in any response body — there is no route that reads it back. The owner reads it in Telegram and answers there.

It is deliberately not a comment. Blog comments are public by default and become visible by reaching status = 'published'; messages live in their own owner_messages table with no publish state at all, so making one public would take new code rather than a wrong click. That separation is the whole design — everything below follows from it.

What it does share with comments is the machinery: the same reader identity, the same Turnstile action pipeline, the same dwell token, the same durable rate limiter, the same Akismet client, and the same kill switch. A message is a different kind of writing, not a different kind of request.

POST /api/v2/messages

Same-origin only, like every /v2 route. 201 on success — including for the silent drops described under Tripwires.

JSON
{
  "body": "I read the piece on quiet architecture twice.",
  "displayName": "someone",
  "email": "you@example.com",
  "turnstileToken": "0.abc…",
  "dwellToken": "1738…:9f2…",
  "website": "",
  "locale": "en"
}
Field Required Notes
body yes 2–4000 characters after trimming, and at most 32 KB of UTF-8
displayName yes 1–32 characters. Overridden by the session’s own name when the sender is a signed-in reader
email yes Must be a valid address. Buys a reply and nothing else — see Why an address is required
turnstileToken yes Action owner_message_create
dwellToken yes Minted by GET /api/v2/comments/dwell-token — the same endpoint, because it signs the same timestamp with the same secret
website no Honeypot. Must be empty
locale no zh or en; anything else falls back to zh. Chooses the language of the verification mail and of the owner’s reply. /message is English only and always sends en
JSON
{
  "id": "1f0c…",
  "createdAt": "2026-09-05T02:14:07.000Z",
  "replyable": true,
  "verificationSent": false
}

replyable and verificationSent are the only interesting fields, and they are what the receipt on /message reads to decide what it can honestly promise:

replyable verificationSent What happened
true false The address is already a confirmed reader. An answer can reach it
false true An address was given but has never been confirmed. A verification mail went out; clicking the link makes a reply possible
false false A tripwire fired, or the verification mail did not go out. Nothing can be answered

Refusals

Status error Cause
400 body must be 2-4000 characters Length, after trimming
400 displayName must be 1-32 characters… Empty, over-long, control characters, or a reserved name
400 email is required and must be a valid address Missing or malformed
400 turnstileToken is required / dwellToken is required Missing
400 turnstile_failed The token did not verify, or carried the wrong action
413 body is too large Over 32 KB, whatever the character count says
429 Too Many Requests See Rate limits
503 turnstile_unavailable Turnstile is unconfigured or unreachable. A refusal, not a bypass
404 not_found Comments are switched off site-wide

That last one is not a typo. The route rides COMMENTS_ENABLED rather than carrying a second switch: the two share the reader identity, the risk stack and the ops bot, so leaving this open while comments are off would keep running exactly the machinery the switch exists to stop.

Why an address is required

The comment box channels rather than blocks: it takes an address when one is offered, because a comment nobody can answer is still worth publishing. This endpoint blocks, because the opposite is true here — a private message nobody can answer is a dead letter, seen once and then stuck.

An address is necessary for a reply, not sufficient. Anyone can type anyone’s address into a public form, and if a reply were mailed to whatever arrived in email, this endpoint would be a way to aim the site’s outbound mail at a stranger. So an unverified address gets the ordinary lazy verification mail — the same one the comment box sends — and a reply becomes possible only once someone has clicked the link in it.

Nothing is lost by ignoring that mail. The message is already stored and the owner already has it; the only thing on the other side of the link is the ability to be answered.

A sender who verifies later is still answerable: the reply path re-resolves the reader by email hash rather than trusting the reader_id that was null at write time.

Rate limits

Three dimensions — anonymous session, hashed IP, and a hashed IP+User-Agent fingerprint — each against two windows:

Window Max
10 minutes 3
24 hours 8

Looser than the comment limits on the short window and much tighter on the long one. Someone writing to the owner legitimately sends a handful of messages ever, not a handful an hour; the burst allowance only exists so that hitting send twice, or writing again after remembering something, is not treated as an attack.

Tripwires

A tripped honeypot, an unsigned dwell token, or a drop heuristic verdict all return 201 with a well-formed body that stores nothing. The envelope has to be indistinguishable from a real success — a distinguishable one teaches a bot exactly which field to leave alone.

Akismet judges everything that survives, with comment_type: contact-form, which is what a private note to a site owner actually is; the classifier weighs it differently from a public comment. Unlike the comment path the verdict is awaited rather than raced against a deadline: no reader is watching for the row to appear, so there is no reason not to file it with its real answer. A spam verdict files the message quietly and skips the Telegram alert. A moderation outage files it as new — the cost of a false negative in a private inbox is one line to skim.

The reply path

New messages arrive as a Telegram alert from the ops bot carrying the sender, the body, and one inline button whose callback data holds the message id. Swipe-reply to that alert and the reply text is mailed to the sender.

The id travels on the alert’s own keyboard, so there is no pending-action state to expire and no way to answer the wrong message by replying late.

Replies ignore the reader’s notify_replies preference: that flag governs unsolicited alerts about other people’s activity, and swallowing a hand-written personal answer because of it would be the wrong reading of a switch nobody set with this in mind. Suppression is still honoured — a hard bounce or a spam complaint means the address is not mailed at all.

For the same reason the reply mail carries no settings link. There is nothing to configure: it is one hand-written answer, not an alert, and the only thing the footer offers is “reply to this email to keep going”.

The bot says why a reply could not be sent rather than failing quietly: the message is gone, there is no address on it, the address was never confirmed, or it is suppressed.

Storage

owner_messages keeps the body, the display name, a hashed address (never the address itself), the locale, a state of new / read / replied / archived / spam, and the same request-shape columns the comments table keeps for abuse work: hashed IP, User-Agent, country, ASN, and fingerprint hash.

There is no retention job. Messages are kept until deleted by hand.