# Memomori API > Memomori is a workspace-based file tree for notes, directories, and blobs. Use its JSON and raw-content HTTP API to list, search, create, edit, move, delete, restore, upload, download, version, and undo entries. The API uses relative URLs on the current origin. Read the full machine-readable contract before making requests. ## API contract - [OpenAPI 3.1 document](/api/v1/openapi.json): Every supported API operation, parameter, body, response, and auth rule. - [API discovery](/api/v1): Authenticated resource URLs and live request limits. ## Authentication Send Authorization: Bearer . A session token can use all routes. An API key needs the read scope for GET, HEAD, and downloads, and the write scope for mutations. API keys cannot sign out, manage API keys, change settings, or create, update, or delete workspaces. A session comes from signing in to the web app, where API keys are also created. ## Core rules - Most JSON responses wrap the result in a data property. Lists also return page.has_more, page.next_cursor, and page.partial. Follow next_cursor until has_more is false. - Entry routes use the default workspace unless the workspace query parameter names one. Use a workspace ID, not its name. - Address one entry with path or id, never both. Paths start at the workspace root and have no leading slash. A write that would give any entry a path past the limit below, numbered suffixes included, answers path_too_long and changes nothing. - from selects active or graveyard. Listing also accepts all. Mutations that restore or destroy graveyard entries must set from=graveyard. - A graveyard entry is read-only. Its content and versions read with from=graveyard, but every write, version restore included, answers entry_not_active until the entry itself is restored. - The graveyard works like a recycle bin. GET /api/v1/entries?from=graveyard lists what was deleted, wherever it lived, and never what went with a deleted directory; parent is where each entry came from, and under narrows the list to what was deleted beneath one live directory. To look inside a deleted directory, list with under_id set to its id: you get only what was deleted with it. A search (q) reaches every graveyard entry. Deleted entries keep their names, so two may share a path; a graveyard path names the most recently deleted, and ids are exact. With include=breadcrumbs a graveyard entry carries only the deleted directories it went with, so an empty list means the entry was deleted itself. - Restoring puts an entry back at its original path and recreates any directory on that path that is gone. Restoring a directory brings back what was deleted with it, not what was deleted from it before. on_conflict decides what happens when the path is taken, including by another entry in the same restore. POST /api/v1/graveyard/empty destroys everything in the workspace's graveyard. - GET /api/v1/entries supports tree listing and search. Use under, depth, kind, q, match, sort, cursor, limit, include, and fields to keep reads small. - JSON entry creation uses POST /api/v1/entries/metadata?path=... which creates a text entry, or a directory when the body carries directory: true. The file name decides which text kind an entry is stored as, on create and again on every rename (.md is markdown, .excalidraw is drawing, everything else is text). PUT /api/v1/entries/content?path=... creates or replaces a text entry from a raw body. POST on that route creates or appends. - POST /api/v1/entries/format?path=... reformats a text entry with Prettier, the same way the editor's Format command does, and saves the result as a new version; it honors If-Match and answers format_unsupported for file types Prettier does not cover. - GET /api/v1/entries/content returns text directly and honors a single Range for text. Blob reads redirect to a signed URL by default; set redirect=false to stream through the API. Use download=1 for an attachment response. Drawing downloads and ZIP exports include image bytes for portability; ordinary reads keep asset references. - Entries carry sha256, the hex SHA-256 of a text entry's content, and md5, the hex MD5 of a blob's bytes as the storage reports it; each is null for the other kinds, and md5 stays null for a blob until a background sweep records it. Compare them with a local file to tell what changed. content_etag is an opaque short tag of the same content. A version's path_at_version is where the entry lived when that version was saved, not where it lives now. - POST /api/v1/drawing-assets?workspace=... uploads raw image bytes with their image Content-Type. Use its id and workspace_id as files[fileId].asset = {id, workspaceId} in an Excalidraw scene, without dataURL. GET /api/v1/drawing-assets/{id}?workspace=... reads the image with workspace authorization. Unreferenced uploads expire after 24 hours; retained drawing versions keep their images. - PATCH and DELETE of one entry accept If-Match with the ETag returned by a prior read. PUT content also honors If-Match when it replaces an entry. - Send Idempotency-Key on retryable mutations. Reuse the same key only for the same method, URL, query, content type, precondition, and body. The two signed-upload steps each need their own key. POST /api/v1/entries/download is not idempotent. - Bulk PATCH, destroy, and copy accept exactly one selector: paths or ids. - POST /api/v1/entries/download streams a ZIP of a selection and answers download_too_large past the limits below. For everything at once, a signed-in user requests a data export: POST /api/v1/exports answers 202 with a pending export and builds one ZIP of every workspace, a folder each, in the background. Only one export may be pending at a time; another request answers export_in_progress. An account keeps one ZIP: a new request expires the ready export and deletes its ZIP. GET /api/v1/exports lists the account's exports, newest first; GET /api/v1/exports/{id} adds url, a signed link valid for an hour, once status is ready. 14 days after the request the ZIP is gone and status reads expired; the list keeps the record for a year. These routes need a session. - To upload files, call POST /api/v1/entries/uploads, PUT bytes to each returned signed URL, then call POST /api/v1/entries/uploads/complete with the tickets. Text in another encoding (UTF-16, Shift-JIS, EUC-JP, ISO-2022-JP, Windows-1252, Latin-1) is decoded and stored as UTF-8, a byte order mark is dropped, and the first version's metadata notes the original under source_encoding. - Upload preparation accepts on_conflict: rename (default) keeps both files with a numbered suffix; replace updates the existing file at that path; error reports name_taken without changing it. Completion uses the prepared choice unless its body sets on_conflict. After name_taken, retry only the conflicted tickets with rename or replace and a new Idempotency-Key. Replace keeps the file ID and text version history; binary files have no version history. Replacing text with binary or binary with text sends the old file to the graveyard and creates a new one. Directories cannot be replaced; a locked file can, and stays locked. Conflicts are local to the active folder and workspace and ignore case. - PATCH starred to true or false to change your personal stars. Only active files and directories can gain a star; graveyard entries keep existing stars and can be unstarred. List with starred=true, depth=inf, and from=all. Stars follow entry IDs through moves and renames. - A lock guards a text entry against accidental edits: PUT and POST /api/v1/entries/content (save and append) and POST /api/v1/entries/format answer file_locked until a PATCH sets locked to false. These still change it and leave it locked: restoring a version, replacing it by upload or restore, and rewriting its links when a target moves. It can also be renamed, moved, copied, trashed, restored or destroyed. With the settings autoLock set to true, a daily sweep locks every text entry that has gone autoLockDays days (1 to 3650, default 30) without an edit or an unlock; turning it on or changing the days applies it at once. Set both through PATCH /api/v1/settings. - GET /api/v1/events?workspace=... is a text/event-stream of content changes in one workspace. Each change event carries id and content_etag for a text entry whose content was saved; compare content_etag with your copy to tell your own writes apart. A resync event means changes may have been missed, so re-read what you hold. The stream ends after at most 10 minutes; reconnect. - GET /api/v1/audits is the account's audit log, newest first, kept 3 months: sign-ins and sign-outs, sessions, API keys, connected apps, settings, billing, exports, downloads, workspaces, every change to an entry, every read of an entry's content (once an hour per entry and session or key; a request sent with Purpose: prefetch is not a read) and every search, each with its actor (user, api_key, oauth_client or system), IP address, user agent and the device it names. Filter with workspace, scope=account, entry and action (entry.move, or a prefix such as entry.). It needs a session. - A create, copy, upload, rename, move, trash, restore, lock or star returns operation. POST /api/v1/operations/{id}/undo puts the entries back for 24 hours, only for the session or key that made it; GET /api/v1/operations?workspace=... lists what you can still undo. An undo returns its own operation, and undoing that redoes the original. - Errors use error.code, error.message, optional error.fields or error.field_errors, and error.request_id. Do not retry validation, auth, conflict, or precondition errors without changing the request. Respect Retry-After on rate limits and in-progress idempotent requests. - Each email gets one 14-day trial; after it the one plan, Pro, is billed monthly, or yearly when chosen on the Stripe Checkout page. GET /api/v1/billing (also carried as billing on GET /api/v1/me) reports state, writable, trial_ends_at, checkout_trial_end (when a checkout started now first charges: the trial end, or two days from now when less is left, since Stripe refuses a nearer one; null once the trial is over, which means at once) and storage use; plan is the price Stripe charges the subscription, or the checkout price for an account without one, and null when Stripe can't be read. While writable is false every mutation answers 402 subscription_required; reads, search, downloads and exports keep working. POST /api/v1/billing/checkout returns a Stripe Checkout url to subscribe at, POST /api/v1/billing/checkout/complete with {"session_id"} reads that checkout back from Stripe when the browser returns, so the subscription shows without waiting for the webhook, POST /api/v1/billing/portal a Customer Portal url to switch between monthly and annual, manage or cancel, and GET /api/v1/billing/invoices the account's newest invoices, newest first, with links to view and download each; all need a session. Workspaces are unlimited, though an account keeps at least one: DELETE /api/v1/workspaces/{id} answers last_workspace for the only one. Note text, every version of it the history keeps, uploaded files and drawing images share 30 GiB per account; search indexes don't count. A trashed file keeps its bytes until it is destroyed, and a write past the limit answers storage_exceeded. - DELETE /api/v1/me with {"confirmation":"DELETE"} schedules the account for deletion 30 days later and requires a session. It stops future charges at once: a subscription in good standing cancels at its period end, any other ends now, open invoices are voided, and nothing is refunded. Until the date in billing.deletion_scheduled_for the user can still sign in, but writes answer account_deletion_pending. POST /api/v1/me/cancel-deletion undoes it without renewing anything: a subscription stays scheduled to cancel until renewed through POST /api/v1/billing/portal. After 30 days the background job removes the account and all owned workspaces. Email and original trial expiry survive deletion to prevent repeat trials. Signup remains blocked until the account is purged. ## Endpoints - GET /api/v1/openapi.json - POST /api/v1/auth/sign-out - GET /api/v1; GET, DELETE /api/v1/me; POST /api/v1/me/cancel-deletion - GET, POST /api/v1/workspaces; PATCH, DELETE /api/v1/workspaces/{id}; GET /api/v1/workspaces/stats - GET, PATCH /api/v1/settings - GET /api/v1/billing; POST /api/v1/billing/checkout; POST /api/v1/billing/checkout/complete; POST /api/v1/billing/portal; GET /api/v1/billing/invoices - GET, POST /api/v1/api-keys; DELETE /api/v1/api-keys/{id} - GET /api/v1/audits - GET /api/v1/sessions; DELETE /api/v1/sessions/{id} - GET /api/v1/connected-apps; PATCH, DELETE /api/v1/connected-apps/{id} - GET, POST /api/v1/exports; GET /api/v1/exports/{id} - GET, PATCH /api/v1/entries - POST /api/v1/entries/destroy; POST /api/v1/entries/copy; POST /api/v1/entries/clip; POST /api/v1/entries/download - POST /api/v1/graveyard/empty - POST /api/v1/entries/uploads; POST /api/v1/entries/uploads/complete - GET, POST, PATCH, DELETE /api/v1/entries/metadata - GET, HEAD, PUT, POST /api/v1/entries/content; POST /api/v1/entries/format - GET /api/v1/entries/versions; GET /api/v1/entries/versions/{version}; POST /api/v1/entries/versions/{version}/restore - GET /api/v1/events - GET /api/v1/operations; POST /api/v1/operations/{id}/undo ## MCP POST /mcp/v1 on this origin is a streamable-HTTP MCP server wrapping this API as tools; it accepts no other method and reads its credential from the Authorization header only. MCP clients sign in through OAuth 2.1: /.well-known/oauth-protected-resource/mcp/v1 names this origin as the authorization server, which publishes /.well-known/oauth-authorization-server, registers clients at /oauth/register, asks the user for consent at /oauth/authorize and issues tokens at /oauth/token. At consent the user picks how long the access lasts: 7, 30 (the default), 90 or 365 days, or never. No token outlives that date, and a refresh after it answers invalid_grant, so the client must ask the user again. An OAuth access token authenticates like an API key limited to its scopes. A plain API key in the Authorization header also works. Every tool call is an ordinary request to the routes above, made with the caller's own credential. An MCP message and a tool result each hold at most 4 MB, so read and write larger notes through the routes above, and each user may send 600 MCP messages a minute. The server's source is internal/mcpserver in the repository. ## Limits GET /api/v1 reports current limits. The present ceilings are 64 MB for a JSON or raw content body, 100 files per upload batch, 500 rows per page, 10000 files and 2 GiB per download, and 2048 bytes per entry path.