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