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 |
Minimum client version and configured capabilities | — |
GET /api/v1/server/ok |
Liveness response { "ok": true } |
— |
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 |
POST /api/v1/users/reset |
Development-only full user reset; 403 outside dev |
— |
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 /.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 |
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 }]; no emails |
PUT /api/v1/events/:eventId/attendance |
{ attending: boolean } |
Fresh attendee list |
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; 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 |
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 |
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.
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 |
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.
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 } |
GET /api/v1/users/connections/musubi |
Session | Decrypted remote connections for the owner |
POST /api/v1/users/connections/musubi |
Session | { server, userID, token } |
DELETE /api/v1/users/connections/musubi |
Session | { server } |
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.