Docs API
Notify API
Subscribe, confirm, unsubscribe, and manage a mood-update email subscription — the Turnstile gate, the token-in-URL auth model, and which routes return HTML instead of JSON.
The notify surface is one email subscription flowing through nine public
routes. Four of them (confirm, unsubscribe’s GET, change-email, and
delete-record) are meant to be clicked from an email client, not called by
a script — they render an HTML result page, not JSON. The rest are JSON for a
form on the site to call directly. See
API Overview for how the Turnstile and
token-in-URL tiers fit into the site-wide auth picture.
Dispatch, scheduling, retry, and preview are cron- and secret-gated rather than public; they are listed in Internal Endpoints.
Every destructive change here is two steps across two requests: a JSON route that mails a confirmation link, and an HTML route the recipient opens to commit it. Nothing on a subscriber record changes on the first call.
Subscribe
POST /api/notify/subscribe
Body:
{
"email": "you@example.com",
"channels": ["mood"],
"deliveryMode": "immediate",
"timezone": "Australia/Melbourne",
"dailyHour": 8,
"turnstileToken": "..."
}
channels is any subset of mood | blog | privacy | announcement.
deliveryMode is immediate | every_5h | daily; dailyHour only
matters when deliveryMode is daily. The Turnstile token can arrive as
turnstileToken, cfTurnstileResponse, captchaToken, or the
cf-turnstile-response header — the handler checks all four before
rejecting. Rate limit: 120 requests / 10 min.
Success is always 200, and it does not reveal whether the address was
already subscribed:
{ "status": "confirmation_sent", "email": "you@example.com", "deliveryMode": "immediate" }
status is "confirmation_sent" for a new signup or "already_subscribed"
for an address already active — a client should show the same “check your
inbox” message either way, since distinguishing them would let a caller
enumerate subscribed addresses.
Errors: 400 {"error":"Invalid JSON body"} for a malformed body;
400 {"error":"Turnstile verification failed","code":"..."} for a rejected
token; 503 {"error":"Turnstile verification unavailable","code":"verify_unavailable"|"not_configured"}
if Turnstile itself can’t be reached — retry this one, don’t tell the user
their input was wrong. Domain errors from NotifyServiceError (bad email,
unknown channel, etc.) surface as {error.status} {"error":"<message>","code":"<code>"}.
Confirm
GET /api/notify/confirm?token=...
Not a JSON endpoint. This is the link from the confirmation email —
opening it in a browser confirms the subscription and renders a full HTML
result page (renderNotifyPage), success or failure, styled like the rest
of the site. A missing token renders the same error page rather than a
400 status, since a human reading it in a browser is the only realistic
caller. Rate limit: 30 requests / 10 min; a rate-limited hit gets a plain
429 Too Many Requests text response instead of the templated page.
Unsubscribe
GET /api/notify/unsubscribe?token=...
POST /api/notify/unsubscribe
Two different behaviors sharing one path, both HTML, not JSON:
GETis the link a human clicks from an email footer. It validates the token (previewUnsubscribeToken) but does not unsubscribe on its own — it302s to/subscribe/manage?token=...&intent=unsubscribeso the actual state change happens on a page the person can see and confirm, withCache-Control: no-store,Referrer-Policy: no-referrer, and a locked-down CSP on the redirect response so the token doesn’t leak via aRefererheader on the next hop.POSTis the actual one-click unsubscribe — this is what a mail client’sList-Unsubscribe-Postsupport calls automatically, no page view required. It unsubscribes immediately and returns the HTML result page directly (still HTML — this is not meant for programmatic callers either).
token for POST is read via readNotifyTokenFromRequest, which accepts
either a token form field/query param or a JSON body — whatever the
sending mail client uses. Rate limit: 30 requests / 10 min on both methods,
tracked under the same bucket.
Manage
GET /api/notify/manage?token=...
PATCH /api/notify/manage
The one pair of notify routes that is plain JSON — this is what the
/subscribe/manage page on the site calls to read and edit a subscription
in place. token is always read from the ?token= query parameter, even
on PATCH.
GET returns the current subscription view (channels, delivery mode,
timezone, status). Rate limit: 120 requests / 10 min, edge-counted.
PATCH applies a partial update — send only the fields you’re
changing:
{ "status": "unsubscribed", "channels": ["mood"], "deliveryMode": "daily", "timezone": "UTC", "dailyHour": 9 }
timezone and dailyHour accept null explicitly (clear the override),
distinct from omitting the field (leave it unchanged) — the handler checks
typeof before forwarding, so an omitted key and an explicit null are not
the same request. Rate limit: 60 requests / 10 min, durable (a
strongly-consistent Durable Object counter, not the usual edge counter) —
this route is the one place in the whole API where the durable limiter is
used specifically to stop a client’s own rapid-fire toggle requests from
racing each other into an inconsistent state. Both methods respond with
Cache-Control: no-store, max-age=0.
Errors on both: 400 {"error":"Invalid JSON body"} (PATCH only), or a
NotifyServiceError surfaced as {status} {"error":"<message>","code":"<code>"}
— most commonly an expired or invalid token.
Request a manage link
POST /api/notify/manage/request
For someone who deleted the original email: send an address, get a fresh
manage-link email if that address has a subscription. Turnstile-gated
exactly like subscribe (same three body fields plus the header
fallback), and deliberately vague on success for the same enumeration
reason:
{ "status": "link_sent" }
Rate limit: 30 requests / 10 min. Same 400/503 Turnstile error split as
subscribe.
Change the subscribed address
Two requests. manage/email mails a confirmation to the proposed address;
change-email is what the recipient opens to commit it.
POST /api/notify/manage/email?token={manageToken}
{ "newEmail": "new@example.com" }
The manage token stays in the query string, the new address goes in the body. Body cap: 16 KB, streamed and aborted mid-read once exceeded, so an oversized payload never lands in memory. Rate limit: 5 requests / 60 min, durable — one of only three genuinely enforced limits on the whole surface, because this is the one endpoint that puts mail in an inbox the caller has proven no relationship to.
Success is always:
{ "status": "change_email_sent" }
Including when the target address is already subscribed, in which case no mail is sent at all. Worse for an attacker, the two branches are timed to match: the “already taken” path sleeps until at least 500 ms have elapsed so it cannot be distinguished from the much slower real path by response time. You cannot use this route to probe whether an address has an account.
Errors: 401 {"error":"Invalid manage link","code":"invalid_manage_token"}
for a missing or bad token; 400 …"code":"invalid_email" for a malformed
address or one over 254 characters; 400 …"code":"email_unchanged" when the
new address equals the current one; 400 {"error":"Request body is too large"};
405 Method Not Allowed (plain text) for anything but POST.
GET /api/notify/change-email?token={changeToken}
POST /api/notify/change-email
HTML, not JSON. The link from the confirmation email. GET previews the
change and renders a confirmation form; the POST that form submits commits
it. The change token is separate from the manage token and expires after 1
hour. Rate limit: 30 / 10 min, and a rate-limited hit gets plain
429 Too Many Requests rather than the templated page. The POST must be
same-origin.
Opening an already-used link is rendered as a success (“that address is already confirmed”), not an error — a second click from a mail client that prefetches links should not look like a failure to the person reading it.
Delete the record
Same two-step shape, and the second step is irreversible.
POST /api/notify/manage/delete?token={manageToken}
No body. Rate limit: 5 requests / 60 min, durable. Success:
{ "status": "delete_link_sent" }
The confirmation always goes to the subscriber’s current address, never to a historical one — an address that was on the record before a change may have been reassigned to someone else since, so it is never treated as an authorization factor.
Calling this twice does not send two emails. While an unconsumed, unexpired
delete request already exists for the record, the route returns the same
delete_link_sent and mails nothing, so a leaked manage link cannot be used as
an email-amplification primitive.
Errors: 401 …"code":"invalid_manage_token", a NotifyServiceError
surfaced as {status} {"error":"<message>","code":"<code>"}, or
500 {"error":"Internal Server Error"}. 405 Method Not Allowed (plain text)
for anything but POST.
GET /api/notify/delete-record?token={deleteToken}
POST /api/notify/delete-record
HTML, not JSON. GET renders the confirmation form, the same-origin POST
performs the deletion. The delete token expires after 1 hour and is
single-use. Rate limit: 30 / 10 min.
Response headers
Every JSON route here sets Cache-Control: no-store, max-age=0. The HTML
result pages additionally set Referrer-Policy: no-referrer and a locked-down
CSP, so a token in the URL does not leak to a third party through a Referer
header when the page loads or the reader clicks onward.