HTTP API
Every endpoint the app answers on, read out of the route files themselves — the methods they export, and the scope each one demands. A handler that changes the scope it requires changes this page on the next build.
Scopes
A token holds some of these and nothing else. Each description is the sentence somebody agrees to when they grant it.
| Scope | What it permits |
|---|---|
streams:write |
Send readings into your data streams, and create new streams |
streams:read |
Read everything your data streams have ever recorded |
schedule:read |
Read everything on your calendar for the days ahead, today included |
schedule:write |
Put blocks on your week — one-off and repeating — move and rename them, take them off a day, set reminders on them, and mark them done or skipped |
today:read |
See today's plan — the blocks and the tasks on it |
habits:read |
See your habits, which are due today, and whether you kept them |
habits:write |
Mark a habit kept, or unmark one |
plugin:declare |
Name and describe itself on your integrations page |
webhooks:manage |
Send itself a message when something changes here — a task finished, a block done — to an address it chooses |
shopping:read |
See everything on your shopping list |
shopping:write |
Add to your shopping list, tick things bought, and take things off it |
calendar:read |
Show your plan in a calendar app. It can see the plan and change nothing |
notes:read |
Read your diary and your notebooks |
notes:write |
Write in your diary and your notebooks |
ideas:read |
See your ideas |
ideas:write |
Add ideas, change them, and remove them |
tasks:read |
Read your todo list and your goals |
tasks:write |
Add, finish and delete todos, move them on and off a day, and close a goal |
kitchen:read |
Read your recipes |
kitchen:write |
Add and change recipes |
people:read |
See the people in your life, and whose birthday is coming |
people:write |
Add people, and change what is recorded about them |
search:read |
Search everything you have written, in one go |
Endpoints
| Endpoint | Method | Scope |
|---|---|---|
/.well-known/assetlinks.json |
GET | — |
/account/export |
GET | — |
/admin/stop |
POST | — |
/api/billing/paddle |
POST | — |
/api/capture-options |
GET | — |
/api/client-errors |
POST | — |
/api/imports |
GET | — |
/api/jobs/reminders |
POST | — |
/api/jobs/weekly-reviews |
POST | — |
/api/live |
GET | — |
/api/mcp |
POST | — |
/api/mcp |
GET | — |
/api/pricing |
GET | — |
/api/push |
POST | — |
/api/push |
DELETE | — |
/api/push/test |
POST | — |
/api/reminders |
GET | — |
/api/reminders |
POST | — |
/api/search |
GET | — |
/api/subscribe |
POST | — |
/api/tutorial |
POST | — |
/api/v1/me |
GET | — |
/api/v1/plugin |
GET | plugin:declare |
/api/v1/plugin |
PUT | plugin:declare |
/api/v1/plugin |
DELETE | plugin:declare |
/api/v1/schedule/upcoming |
GET | schedule:read |
/api/v1/shopping |
GET | shopping:read |
/api/v1/shopping/items |
POST | shopping:write |
/api/v1/shopping/items/[id]/bought |
POST | shopping:write |
/api/v1/streams |
GET | streams:read |
/api/v1/streams |
POST | streams:write |
/api/v1/streams/[slug]/points |
POST | streams:write |
/api/v1/streams/[slug]/points |
GET | streams:read |
/api/v1/streams/[slug]/points/[externalId] |
DELETE | streams:write |
/api/v1/today |
GET | today:read |
/api/v1/webhooks |
GET | webhooks:manage |
/api/v1/webhooks |
POST | webhooks:manage |
/api/v1/webhooks/[id] |
DELETE | webhooks:manage |
/calendar/[token] |
GET | calendar:read |
/healthz |
GET | — |
/manifest.webmanifest |
GET | — |
/media |
POST | — |
/media/[id] |
GET | — |
/robots.txt |
GET | — |
/settings/account/export |
GET | — |
Webhook events
What a subscription can ask to be told about. Managed through
/settings/integrations or the webhooks:manage scope.
| Event | Fires when |
|---|---|
todo.created |
a todo is added |
todo.completed |
a todo is finished |
idea.created |
an idea is captured |
diary.created |
a diary entry is written |
shopping.added |
something goes on the shopping list |
shopping.bought |
something on the list is bought |
/.well-known/assetlinks.json
Digital Asset Links, which is what removes the URL bar from the Android app.
A Trusted Web Activity is Chrome rendering this site inside our own APK. It only drops the address bar once it can prove the two belong together, and the proof is mutual: the APK names this domain, and this file names the APK's signing certificate. Get it wrong and the app still works — it just looks like a browser, which is the single most common TWA complaint.
The fingerprints come from the environment because they are a property of the
signing keys, which differ between a local debug build and whatever Play
signs. ANDROID_CERT_FINGERPRINTS is a comma-separated list of SHA-256
fingerprints in the usual colon-separated hex form.
List both your upload key and Play's app-signing key: Play re-signs uploads, so an app that only trusts the upload key shows the URL bar for every user who installs from the store while working perfectly on the developer's phone.
GET
/account/export
The export moved with the page it hangs off.
GET
/admin/stop
Give the account back.
Posted from the banner that sits over every page while an administrator is borrowing a session. better-auth swaps the cookie back to the administrator's own session; the note in the borrowed account's history is what makes the whole thing visible to the person it happened to.
POST
/api/billing/paddle
Where Paddle tells us what happened.
Unauthenticated by design — the signature is the authentication. The raw body is read as text and hashed before anything parses it, because re-serialising JSON changes bytes and the comparison would fail for a reason nobody could see.
Always answers 200 once the signature checks out, including for events it ignores: a provider that gets an error retries, and retrying something we deliberately did nothing with is noise for both sides.
POST
/api/capture-options
The choices the capture dialogs offer, fetched when one opens.
Capture lives in the app shell, which is on every page — so putting these into the layout's own load would run three queries on every request to serve a dialog most visits never open. Asked for once, the first time one is opened, and held for the rest of the session.
GET
/api/client-errors
Where the page sends what broke, and the answer it was given.
Session-authenticated like /api/search — this exists for the page that is
already open, not for a plugin. One body, two shapes: { decision } records
the person's yes or no, { error } is a report, accepted only after a yes —
or with once, which is the error page's own button and speaks for that one
report only.
POST
/api/imports
What this instance can bring things in from, for whoever is listing it.
ontoplano.com's FAQ answers "can I bring my tasks over" by name, and it is a
different repository with no database, built on a laptop — so the list was
typed there by hand and went stale the day Google Keep landed. This is the
same arrangement /api/pricing has for the one number that must not be typed
twice: the app is the source, this is how anything outside reads it, and
make deploy-site fetches it at build time.
Public and cacheable, and it discloses nothing: it is a list of other people's products, identical on every instance of this version.
GET
/api/jobs/reminders
The minute's reminders, done by the process that is already running.
The timer used to be npx tsx scripts/deliver-reminders.ts, which is a fresh
Node, a fresh TypeScript compile of the whole service graph and a fresh
database handle — about three seconds of CPU and a hundred megabytes, every
minute, for a job whose usual answer is "nothing is due". That is four per
cent of a core burned permanently on a small box, and it was noticed exactly
as it should have been: by somebody reading the journal and asking whether
that could possibly be right.
The app has the code loaded and the database open. Asking it costs a request.
Behind the health token, which the box already has for /healthz: this
writes and sends, so it is not for the public. Absent token, absent
endpoint — never open, whatever is misconfigured.
POST
/api/jobs/weekly-reviews
The hour's weekly review mail, done by the process that is already running.
The same shape as /api/jobs/reminders, for the same reason: the app has
the code loaded and the database open, so a timer — or the Docker image's
own scheduler, which has no systemd to lean on — asks it instead of booting
a second copy of everything. Hourly, because seven in the morning is a
different instant for every timezone; sendWeeklyReviews already does
nothing for the twenty-three runs that are not somebody's seven.
Behind the health token, like the reminders job: it sends mail, so it is not for the public. Absent token, absent endpoint.
POST
/api/live
The stream a tab holds open to hear that its data moved.
Session only — no bearer token. A token is for something acting on the account from outside; this is the account's own browser being told what that something did, and there is no reason to hand a script a tap on somebody's activity.
The two lines that are not optional
X-Accel-Buffering: no turns off nginx's response buffering for this
response only. Without it nginx holds the bytes until its buffer fills, which
for a stream of 60-byte messages is never — so the page hears nothing for
minutes and then everything at once. Cache-Control: no-store stops anything
in between deciding a stream is a document worth keeping.
The heartbeat is a comment line every 25 seconds. It carries nothing; it
exists because proxies close idle connections at 30 or 60, and a stream that
dies silently is one where EventSource waits its full backoff before
noticing.
GET
/api/mcp
The one address an assistant talks to.
Streamable HTTP, in its simplest legal form: a POST carrying one JSON-RPC message (or a batch of them) and a JSON answer. No session id, no event stream, nothing kept between calls — every request carries its own bearer token and is answered on its own.
Authentication is the app's existing API token, the same one a plugin uses, with the scopes decided when it was made. A token with no assistant scopes authenticates fine and is offered no tools, which is the honest answer to "what can this do here": nothing.
GET answers 405 deliberately. A client that asks to open a server-initiated stream is told there is not one, rather than being left holding a connection that will never carry anything.
POST
GET
What this is, for anybody who opens the address in a browser.
Not the protocol — a GET is how a client asks for a server-initiated event stream, and there is not one. It is a signpost, and it names nothing about the instance beyond what it is.
/api/pricing
What this instance charges, for whoever is quoting it.
There is one number and it has to be the same everywhere: on the billing page, in the terms, and on ontoplano.com — which is a different repository with no database, built on a laptop. The price it prints was typed into that repo by hand, so it could quote $4.90 while the checkout took something else, and nothing anywhere would notice.
So the provider is the single source, this endpoint is how anything outside
the app reads it, and make deploy-site fetches it at build time. Changing a
price is changing it at the provider and deploying; nothing is typed twice.
Public and cacheable on purpose: it is the number on a public page, and it discloses nothing an anonymous visitor cannot already read. It carries no account, no ids and no provider keys.
GET
/api/push
A browser signing itself up to be interrupted, or asking to stop.
Session-authenticated like /api/reminders rather than token-based: the
caller is the page somebody has open, and the account is taken from that
session rather than from the body — a subscription belongs to whoever was
signed in when their browser said yes, and nothing a page sends can make it
belong to anybody else.
POST
DELETE
/api/push/test
Push one notification to this account's devices, now.
The chain between "I pressed allow" and "my phone buzzed" has six links in it — permission, a subscription, a row, keys, a timer, a push service — and when nothing arrives, every one of them is a candidate. Nobody should have to bisect that by setting a reminder and waiting a minute.
So: the same code path a real reminder takes, on demand, answering with what happened rather than with a status code. It is not a debug endpoint that bypasses anything — it pushes for real, which is the point.
POST
/api/reminders
What should have gone off by now.
Polled by the page that is already open, so it is session-authenticated like
/api/search rather than token-based like /api/v1. The reads and the
"I have shown this" are separate calls on purpose: a reminder is only marked
delivered once something has actually put it in front of somebody, so a
failed request loses nothing.
GET
POST
/api/search
Search, for the command palette.
Session-authenticated like the rest of the app rather than token-based like
/api/v1 — this exists for the page that is already open, not for a plugin.
GET
/api/subscribe
The one public endpoint the newsletter form posts to.
The form is in the footer of ontoplano.com, which is a different origin from
this app and a directory of static files with nothing behind it — so the
post comes here, cross-origin, and the browser will not send it without
being told. Which origin is allowed is the instance's own setting, echoed
back only when it matches exactly: an Access-Control-Allow-Origin that
reflects whatever asked is not a CORS policy.
On an instance with no newsletter this route is a 404 in both methods, so a self-hosted install does not advertise an endpoint it will refuse.
What it answers
The same thing, always: accepted. Whether the address was new, already on the list, or previously unsubscribed is not the form's to disclose — the moment those answers differ the form is a way to ask "is this person a subscriber?" about anybody.
POST
/api/tutorial
"I have been shown around."
Session-authenticated and one line long, like /api/client-errors: the shell
mounts the tour on every page, so there is no page whose form action could
own this. It is a preference the chrome writes about itself, not a mutation
of anything a page is showing.
The demo never posts here. Its account belongs to one visitor for a few hours and the next visitor gets a different one, so a dismissal there is remembered by the tab and nowhere else.
POST
/api/v1/me
Token introspection — lets a producer verify its credentials and discover the user's timezone during setup, without needing any other scope.
GET
/api/v1/plugin
The manifests this account has been given.
GET — requires plugin:declare
PUT — requires plugin:declare
Declare what this plugin understands. Idempotent per source, so a producer can call it at every startup and the newest version's vocabulary wins.
DELETE — requires plugin:declare
Withdraw a manifest. The metadata keys keep working; they just lose their label.
/api/v1/schedule/upcoming
Upcoming scheduled occurrences, so an external app can act on the plan.
This is what an alarm app reads to set alarms from planner slots. Ontoplano
reports what is scheduled; deciding which occurrences deserve an alarm —
and what kind — is the consumer's business, matched on title, category
or label.
GET — requires schedule:read
/api/v1/shopping
The whole list, bought and waiting alike — the reader decides what matters.
GET — requires shopping:read
/api/v1/shopping/items
Put something on the list.
The same semantics as typing it in the app: a name already held is put back
on the list rather than duplicated, and the response says which happened.
category is a name, created if new — a producer should not need a second
request to find out what number "Dairy" is.
POST — requires shopping:write
/api/v1/shopping/items/[id]/bought
State, not a toggle: { "bought": true } twice means bought, not un-bought.
A plugin mirroring two lists needs to be able to repeat itself.
POST — requires shopping:write
/api/v1/streams
List the caller's streams.
GET — requires streams:read
POST — requires streams:write
Declare a stream. Idempotent per slug — safe to call at every producer startup.
/api/v1/streams/[slug]/points
Push points. Batch, idempotent, partial success.
Re-sending an already-stored point is reported under duplicates and is not
an error — producers should treat it as success and mark the reading synced.
POST — requires streams:write
GET — requires streams:read
Read points back, for reconciliation or charting.
/api/v1/streams/[slug]/points/[externalId]
Delete a single point by its producer-supplied id.
DELETE — requires streams:write
/api/v1/today
Today's plan, for the home-screen widget.
Separate from /schedule/upcoming, which answers "what is coming" for an
alarm consumer. This answers "what does today look like", which is a
different question and a different scope: a widget on a lock screen should
not carry a token that can also read a week ahead.
Habits come with it only for a token that was also granted habits:read.
The two used to be one permission, so the widget's token could report which
habits were kept — which nobody agreed to by ticking a line about the day's
plan.
GET — requires today:read
/api/v1/webhooks
List the caller's webhook subscriptions.
GET — requires webhooks:manage
POST — requires webhooks:manage
Subscribe an address to events.
The response carries the secret deliveries are signed with — keep it, and
verify X-Ontoplano-Signature (sha256= + HMAC-SHA256 of the raw body).
/api/v1/webhooks/[id]
DELETE — requires webhooks:manage
/calendar/[token]
The plan, as a calendar anybody's software can subscribe to.
Unauthenticated in the session sense on purpose: this URL is pasted into Google Calendar or an iPhone, which will fetch it from their own servers with no cookie, no header and no way to be asked anything. The secret is the URL — the same bargain Google makes with its own "secret address in iCal format".
Which is why the token in the path must hold calendar:read and nothing
else. A URL is written into config files, walked past by every proxy in
between, and sometimes handed to a partner; the one thing that keeps that
bounded is that the credential it carries cannot do anything but this. A
powerful token pasted here is refused rather than honoured, so nobody can
arrive at a working feed with a key that also writes.
GET — requires calendar:read
/healthz
Is this box alive, and is it actually able to work?
A process that is listening is not the same as a process that can serve, and on a small machine the difference is the whole problem: the disk fills, or SQLite is locked by a backup, and every page 500s while the port stays open. So this touches the database rather than answering from memory.
No session and no auth, because whatever is watching this is not logged in and should not have to be. It says nothing an attacker could not learn by loading the login page.
The disk and memory numbers are the exception, and they are behind a token.
"This box is 94% full" is a sentence that tells somebody exactly which
attack is cheap today, so it is for the machine that is watching and nobody
else. Set ONTOPLANO_HEALTH_TOKEN and send it as x-health-token or
?token=; without one configured, nothing is ever disclosed.
GET
/manifest.webmanifest
The installed app's own identity, which is not the same on every instance.
Served rather than shipped as a static file for one reason: two copies of this app can be installed on one phone — the instance somebody actually uses and the staging one — and if they claim the same name, the same id and the same icons, they are indistinguishable on a home screen. The week then goes into whichever one was tapped, which is a data loss nobody would think to report as a bug.
Everything else here is production's manifest verbatim. Staging changes the three things a launcher shows and nothing else, because a staging instance that differs in any other way has stopped standing in for the one it copies.
GET
/media
Somewhere to put a picture, before the writing that mentions it exists.
This is an endpoint rather than a page action, and that is a deliberate exception to the rule that pages mutate through actions. The reason is what it is for: pasting a screenshot into a note. The picture has to be stored and given an address while the person is still typing, because the address is what goes into the text — so there is no form being submitted, and nothing on the page changes. The entry itself is still saved by an ordinary form action, with the markdown the person can see and edit.
Nothing is relaxed by that. The session is required, the ceilings and the
type allowlist are the service's, and SvelteKit's origin check covers a
multipart/form-data POST exactly as it covers a form.
POST
/media/[id]
One picture, to the one account it belongs to.
The ownership is in the service's WHERE, so a signed-in stranger asking for
somebody else's id gets the same 404 as an id that never existed — there is
no arithmetic anybody can do on these numbers.
The headers are the other half. nosniff stops a browser from deciding for
itself that a file the service typed as an image is really a document;
Content-Disposition: inline with a filename we sanitised keeps a crafted
name from steering the header; sandbox is not needed because the type
allowlist has no format that can execute anything.
Cached hard and privately: a row here never changes — a different picture is a different id — so a browser may keep it for as long as it likes, and no shared cache may keep it at all.
GET
/robots.txt
What a crawler is welcome to read.
A route rather than a file in static/, because the right answer differs by
instance and a static file cannot know which one it is on.
Worth being honest about what this does and does not do. It is a request, and
only well-behaved crawlers honour it — the addresses fail2ban is banning are
hitting sixty failed requests a minute looking for /wp-admin and .env, and
they have never read a robots.txt in their lives. This is here so that the
pages behind a login do not turn up in a search result, and so that the demo
is not indexed as a second copy of the site; the banning stays fail2ban's job.
Everything under a login already redirects, so a crawler learns nothing from following them — but a redirect still costs a request, and a list of paths that were crawled and bounced is noise in the log.
GET
/settings/account/export
The account's data as a JSON download.
A +server route rather than a form action, because the answer is a file rather than a page — the "no +server routes" convention is about mutations, and this reads.
GET