Notify API

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:

JSON
{
  "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:

JSON
{ "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:

  • GET is the link a human clicks from an email footer. It validates the token (previewUnsubscribeToken) but does not unsubscribe on its own — it 302s to /subscribe/manage?token=...&intent=unsubscribe so the actual state change happens on a page the person can see and confirm, with Cache-Control: no-store, Referrer-Policy: no-referrer, and a locked-down CSP on the redirect response so the token doesn’t leak via a Referer header on the next hop.
  • POST is the actual one-click unsubscribe — this is what a mail client’s List-Unsubscribe-Post support 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:

JSON
{ "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.

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:

JSON
{ "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}
JSON
{ "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:

JSON
{ "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:

JSON
{ "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.