HTTP API
The mobile client uses a JSON REST API plus Server-Sent Events. The application
API is versioned under /api/v1; Better Auth owns /api/auth/*; a few public
HTML and platform-association routes live at the origin root.
This page is an implementation inventory, not a promise of long-term API
stability. Musubi is pre-1.0 and does not currently generate an OpenAPI schema.
The route registry in apps/api/src/index.ts, shared Zod schemas in
packages/types, and handlers in apps/api/src/handlers/ are authoritative.
Authentication
Section titled “Authentication”Most application routes require:
Authorization: Bearer <session-token>requireAuth resolves the token in two stages:
- Better Auth session (
auth.api.getSession); - SHA-256 lookup of a non-expired credential in
member_tokensfor a federated shadow member.
The second path authenticates identity only. Resource handlers must still
authorize calendar or event access through calendar_members and the shared
permission matrix. One known route currently violates that rule, called out
under Public routes and in the audit.
Better Auth also supports its normal cookie-based flows under /api/auth/*.
Use the generated authClient in the app instead of reimplementing sign-in,
sign-up, session, social-linking, password-reset, or deletion calls.
Conventions
Section titled “Conventions”JSON and dates
Section titled “JSON and dates”Application JSON uses the schemas from @musubi/types. Date fields cross HTTP
as ISO 8601 strings and are coerced back to Date by Zod or client/cache
boundaries.
The global JSON body limit is 512 KB. Calendar import is the exception: it
uses text/calendar with a route-local 10 MB limit.
Success and errors
Section titled “Success and errors”Creates normally return 201; reads/updates/deletes return 200; several
side-effect-only routes return an empty 200.
Typed application errors map to:
| Error kind | HTTP |
|---|---|
BadRequest |
400 |
Unauthorized |
401 |
Forbidden |
403 |
NotFound |
404 |
| Unknown/unhandled | 500 |
Uncaught Zod validation failures are also normalized to 400. Event endpoints
validate canonical UUID shape before issuing PostgreSQL queries, and
GET /events?since= rejects non-string or invalid timestamps. Malformed client
input must not surface as a database-flavoured 500.
Error JSON always includes the request correlation id:
{ "error": "You don't have permission to editEvents on this calendar.", "requestId": "..."}The same value is returned in x-request-id and attached to structured logs.
Public routes
Section titled “Public routes”| Method and path | Purpose | Rate limit |
|---|---|---|
GET /api/v1/server |
API version, minimum client version, and configured capabilities |
— |
GET /api/v1/server/ok |
Liveness and deployment identity response { "ok": true, "version": "0.1.3" } |
— |
GET /api/v1/calendars/tokens/:token |
Invite preview; token is the capability | 30 / 15 min / IP |
POST /api/v1/federation/accept |
Accept invite as a remote-server shadow user | 10 / 15 min / IP |
POST /api/v1/users/delete/confirm |
Complete email-token account deletion | 10 / 15 min / IP |
GET /api/v1/users/:userId/avatar |
Public immutable avatar bytes | — |
GET /invite/:token |
HTML hand-off to musubi://invite/... |
— |
GET /reset-password |
Self-hosted password reset HTML | — |
GET /delete-account |
Self-hosted account-deletion HTML | — |
GET /email-verified |
Self-hosted email-verification landing HTML | — |
GET /.well-known/apple-app-site-association |
iOS invite universal-link association | — |
Invite preview and federation accept validate UUID shape, expiry, and usage limit on every request. The rate limiter is in-memory and per process; it is not a distributed security boundary.
Server and realtime
Section titled “Server and realtime”| Method and path | Auth | Response |
|---|---|---|
GET /api/v1/server |
Public | { minClientVersion, socials, syncProviders, email } |
GET /api/v1/server/ok |
Public | { ok: true } |
GET /api/stream |
Required | Long-lived text/event-stream |
SSE frames contain one JSON object:
data: {"type":"event_updated","payload":{...}}Current event types:
| Type | Client reaction |
|---|---|
event_created |
localAddEvent |
event_updated |
localUpdateEvent |
event_removed |
localRemoveEvent |
calendar_updated |
Merge into calendar store |
calendar_removed |
Drop calendar and its orphaned event links |
attendance_changed |
Refresh open attendee state |
external_sync |
Silent delta refresh; do not trigger provider sync again |
settings_updated |
Refetch settings; carries only { revision } |
reminders_updated |
Refetch reminder rules and reschedule; carries no payload |
page_created |
Add the PageDocument in data.page |
page_updated |
Replace the PageDocument; dedupe by revision |
page_removed |
Drop the page id in data.id; the default may have moved |
federated_sync |
Something changed on the connected server in data.server; refetch that server’s data |
Page, settings, and reminder events fan out only to the acting user’s own sessions, including the originating one, which discards the duplicate by revision.
SSE is best-effort and has no replay buffer. The durable recovery path is event delta sync.
Events
Section titled “Events”| Method and path | Request | Response / notes |
|---|---|---|
GET /api/v1/events?since=<ISO> |
Optional delta cursor | { events, deletedIds, serverTime } |
POST /api/v1/events |
Event |
Created Event; validates every linked calendar |
PUT /api/v1/events |
Complete Event |
Updated Event; home calendar controls editing |
DELETE /api/v1/events |
Event plus optional unlinkCalendarID |
{ id, calendars, removed } |
POST /api/v1/events/:eventId/link |
{ calendarID } |
Same event linked into target |
POST /api/v1/events/:eventId/fork |
{ calendarID } |
Independent new Event |
GET /api/v1/events/:eventId/attendees |
— | [{ id, name, image, status }]; no emails |
PUT /api/v1/events/:eventId/attendance |
`{ status: “going” | “maybe” |
Event/calendar identifiers on this surface must use canonical hyphenated UUID
form. Invalid body references, route parameters, unlinkCalendarID, and
since cursors return 400 before authorization or database access.
Event is defined in packages/types/src/event.ts. The important fields are:
type Event = { id: string; creatorID: string; organizer: string; title: string; color: string; start: Date; end: Date; calendars: string[]; originCalendarID?: string | null; isCanceled: boolean; isAllDay: boolean; hasAttendees: boolean; description?: string | null; location?: string | null; recurrence?: string | null; url?: string | null;};Identity and authority are server-owned: create overwrites creatorID; update
does not allow creatorID or originCalendarID to change. The home
originCalendarID must be one of calendars.
Calendars, members, and invites
Section titled “Calendars, members, and invites”| Method and path | Request / response |
|---|---|
GET /api/v1/calendars |
All calendars the user belongs to, enriched with role/provider metadata |
GET /api/v1/calendars/:id |
One calendar + members; membership required |
POST /api/v1/calendars |
Calendar; optional provider/account creates remotely first |
PUT /api/v1/calendars |
Calendar; owner-only calendar metadata update |
DELETE /api/v1/calendars |
Calendar; returns removed row |
POST /api/v1/calendars/import?name=&color= |
Raw iCalendar body, capped at 10 MiB and 10,000 VEVENTs; returns calendar + imported count |
GET /api/v1/calendars/:id/export |
text/calendar snapshot; any member |
GET /api/v1/calendars/tokens/:token |
Minimal invite preview: calendar identity, member names/avatars, 30-day event summaries |
GET /api/v1/calendars/:calendarId/members |
[{ id, name, image, role }] |
POST /api/v1/calendars/members/:calendarId |
{ token }; join as viewer |
DELETE /api/v1/calendars/members/:calendarId |
Leave; owner cannot leave |
PUT /api/v1/calendars/:calendarId/members/:userId |
{ role }; ownership transfer is limited to non-default native calendars |
DELETE /api/v1/calendars/:calendarId/members/:userId |
Remove member; owner cannot be removed |
POST /api/v1/calendars/invites |
Invite |
GET /api/v1/calendars/:calendarId/invites |
Active invite records |
DELETE /api/v1/calendars/invites/:inviteId |
Revoke immediately |
POST /api/v1/calendars/invites/:inviteId/send |
{ email }; mails the EXISTING link, so revoking still kills every copy. Needs invite on that calendar and is capped at 20/hour per account — an IP cap would punish an office and miss a roaming phone |
New invite members start as viewer. An invite can have nullable expiresAt
(never expires) and nullable maxUses (unlimited); uses increments only for a
new membership.
Settings, user, and profile
Section titled “Settings, user, and profile”| Method and path | Request / response |
|---|---|
GET /api/v1/users/settings |
Materializes defaults when missing |
PUT /api/v1/users/settings |
Complete Settings; updates or inserts |
GET /api/v1/users/settings/document |
{ value, revision, updatedAt } for compare-and-swap clients |
PATCH /api/v1/users/me/settings |
{ baseRevision, patch }; 409 with current on revision drift |
DELETE /api/v1/users |
Starts email-confirmed Better Auth deletion |
POST /api/v1/users/delete/confirm |
{ token }; public completion |
POST /api/v1/users/avatar |
{ data: "<base64>" }; JPEG/PNG/WebP, max 256 KB decoded |
GET /api/v1/users/:userId/avatar |
Public image bytes with immutable caching |
Settings is defined in packages/types/src/settings.ts. Optional fields are
intentional so older clients cannot reset newer settings when saving.
timezone and defaultReminder were added for reminders. The server keeps
notificationsOnByDefault in step with defaultReminder in both directions, so
an older client that knows only the boolean still turns reminders on and off for
real — see Reminders.
Reminders
Section titled “Reminders”| Method and path | Request / response |
|---|---|
GET /api/v1/reminders |
{ default, calendars: {id: rule}, events: {id: rule} } — only explicit choices |
PUT /api/v1/reminders/calendars/:calendarId |
{ rule }; null inherits the global default. 403 for a non-member |
PUT /api/v1/reminders/events/:eventId |
{ rule }; null clears the override. Requires only that the caller can see the event |
DELETE /api/v1/reminders/events/:eventId |
Same as PUT with rule: null |
The server stores rules, never a schedule: clients resolve what to ring with
resolveReminders from @musubi/calendar.
Notification emails
Section titled “Notification emails”Sent because a person did something, as opposed to a reminder, which is a
promise you made to yourself. The settings.notificationEmails.eventChanged
switch is on by default.
| What | When | Batched |
|---|---|---|
| An event moved or was cancelled | Only the start/end changing or isCanceled going true. Guests who have not declined, minus whoever made the change |
Yes — pending_notifications, one email per person per batch |
| A calendar invitation | Somebody typed an address and pressed send | No — transactional, and it has no preference for the same reason a password reset does not |
Batched notifications wait BATCH_DELAY_MS (3 minutes) from the first
change, never extended: rearranging an afternoon is one email, and somebody who
keeps fiddling still tells people something. Rows are only deleted once the mail
is accepted, so a failed send is retried — and abandoned after 24 hours, because
nobody needs to hear on Tuesday that Monday moved.
A Page is a private per-user calendar view profile: name, chosen view, visible calendars, and simple filters. Pages are never shared with calendar members — two members of the same calendar keep independent Pages.
| Method and path | Request / response |
|---|---|
GET /api/v1/pages |
Active Pages ordered by position; lazily creates or repairs the default Page |
POST /api/v1/pages |
{ name, config }; server assigns id, position, and revision |
PUT /api/v1/pages/reorder |
{ pageIds, defaultPageId? }; atomically renumbers positions and moves the default |
GET /api/v1/pages/:id |
One owned Page; 404 otherwise |
PATCH /api/v1/pages/:id |
{ baseRevision, name, config }; explicit save |
DELETE /api/v1/pages/:id |
Soft delete; the default deterministically moves to the next survivor |
PageDocument, PageConfigV1, and the request schemas are defined in
packages/types/src/pages.ts. The config is versioned JSONB (schemaVersion
plus a discriminated view union and calendarVisibility) saved atomically
with one revision. Saves are compare-and-swap: a stale baseRevision returns
409 with { error: "PAGE_CONFLICT", current } and never a silent overwrite.
Calendar ids inside the config are not foreign keys — reads ignore calendars the
user can no longer access. A partial unique index enforces at most one active
default Page per user.
PUT /api/v1/pages/reorder is a full replacement write: pageIds must contain
every active owned Page exactly once. Omitting defaultPageId preserves the
current default; including it moves the flag atomically with the order. Position
and default changes increment the affected Page revisions so realtime clients
can apply them.
Provider connections
Section titled “Provider connections”| Method and path | Purpose |
|---|---|
GET /api/v1/calendars/google |
Trigger syncUser (all configured providers despite the legacy path name) |
GET /api/v1/users/connections/google |
Google connection check |
POST /api/v1/users/connections/google/revoke |
Legacy Google disconnect |
GET /api/v1/users/connections/caldav |
List the caller’s CalDAV accounts |
POST /api/v1/users/connections/caldav |
Validate credentials, store encrypted password, strict initial sync |
DELETE /api/v1/users/connections/caldav |
Remove one account and its mirrors |
POST /api/v1/users/connections/disconnect |
Disconnect one { provider, accountId } |
POST /api/v1/users/connections/calendars/disconnect |
Disable one mirrored { calendarId } without touching provider; returns { id } |
The exact-account disconnect accepts google, microsoft, or caldav.
OAuth unlink refusal falls back to clearing calendar credentials only for the
matching (user, provider, accountId) row, so sibling accounts remain
connected. The legacy Google revoke route is provider-wide because its request
shape predates multi-account support and contains no account ID.
Disconnecting one mirrored calendar also publishes calendar_removed to its
members so another open client can evict the mirror and its events immediately.
Provider behavior and limitations: Google, Microsoft / Outlook, and Apple / CalDAV.
Federation
Section titled “Federation”| Method and path | Auth | Body / response |
|---|---|---|
POST /api/v1/federation/accept |
Invite token; optional current member bearer proves an existing shadow | { token, profile: { name, email, homeServer, image? } } → { memberToken, memberTokenExpiresAt, userID, calendar } |
POST /api/v1/federation/token/rotate |
Member token | Compare-and-swap exchange → { memberToken, memberTokenExpiresAt } |
DELETE /api/v1/users/connections/musubi |
Session | { server } — drop a federated connection |
GET /api/v1/federation/connections |
Session | The caller’s federated connections without the credential: [{ id, label, remoteUserID, server }] |
GET /api/v1/federation/preview?server=&token= |
Session | Fetches another server’s public invite preview on the caller’s behalf; rate-limited |
POST /api/v1/federation/connect |
Session | { server, token } — runs the accept handshake server-side and stores the connection; returns { server, calendar } and never a token |
ANY /api/v1/federation/s/:connectionId/api/v1/* |
Session | Gateway: forwards to the connected server with the member token attached |
The gateway (ADR-005) exists so a client never holds a cross-server credential.
:connectionId is a musubi_accounts row id owned by the caller, so the target
origin is read from the database and cannot be influenced by the request; only
/api/v1/* paths are forwarded, the home session cookie is dropped, upstream
status codes are relayed unchanged, and an unreachable origin returns 502
FederatedServerUnreachable. Private, loopback and link-local targets are
refused unless FEDERATION_ALLOW_PRIVATE_HOSTS=true (LAN self-hosting). The
preview and connect routes take the origin from the request rather than the
database, so the SSRF guard is the only boundary there — hence the tighter rate
limits.
The member token has no route out of the server: reading connections with their
decrypted token, and storing a client-supplied one, were both removed once the
clients moved onto the gateway. POST /api/v1/federation/connect is the only way
a connection is created, so a credential can only ever be one this server
obtained itself.
The returned member token is shown once; the origin stores only its SHA-256
hash. It expires after 90 days and the client rotates it in the final 14 days.
Removing the shadow’s last calendar membership revokes its token rows
transactionally. The home server stores a roaming copy encrypted with
CALDAV_ENC_KEY.
Profile claims on a first accept are display-only and always create an isolated shadow. The origin reuses an existing shadow only when the request presents its current bearer token; submitted email/home-server text alone cannot inherit prior memberships. Read the federation trust model before changing these routes.
Adding or changing an endpoint
Section titled “Adding or changing an endpoint”Update all of these seams in one change:
- shared Zod schema/type when the payload is reusable;
- query function in
packages/db/src/queries/; - handler validation, authorization, persistence, provider behavior, SSE;
- route registration and static-before-parameter ordering;
- client method in
apps/client/services/api.ts; - tests for happy, unauthenticated, forbidden, invalid, and retry/idempotency paths;
- this inventory and the relevant architecture page.
The full recipe is in API Server → How to add an endpoint.