Skip to content

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.

Most application routes require:

Authorization: Bearer <session-token>

requireAuth resolves the token in two stages:

  1. Better Auth session (auth.api.getSession);
  2. SHA-256 lookup of a non-expired credential in member_tokens for 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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

Update all of these seams in one change:

  1. shared Zod schema/type when the payload is reusable;
  2. query function in packages/db/src/queries/;
  3. handler validation, authorization, persistence, provider behavior, SSE;
  4. route registration and static-before-parameter ordering;
  5. client method in apps/client/services/api.ts;
  6. tests for happy, unauthenticated, forbidden, invalid, and retry/idempotency paths;
  7. this inventory and the relevant architecture page.

The full recipe is in API Server → How to add an endpoint.