Skip to content
WorkRoad home
REST API

Notes and files

Write note content, handle Markdown conversions, organize folders and upload files.

Writing notes

Note content goes in as Markdown and comes out two ways. GET /notes/{noteId} returns content, the editor's JSON document, and markdown, the same document converted back. The server stores only the editor JSON, so the Markdown you read back is a conversion, not your original text.

POST /notes takes optional markdown for the first version of a note. Two operations write an existing note. PUT /notes/{noteId}/content replaces the whole document. POST /notes/{noteId}/content/append adds your blocks after the current ones and keeps their ids. Send an Idempotency-Key with the append so a retry can't add the blocks twice; without a key, send baseVersion and a retry fails with 409 instead. PUT takes no mode, and a body with one returns 400. An empty or whitespace-only markdown on an append returns 400 invalid_request with details.reason empty-append. An append drops one empty paragraph at the end of the note first, so a new or app-saved note doesn't gain a blank block. A note holds at most 200,000 visible characters, so an append that would pass that returns 400 invalid_request with details.reason note-too-large. A replace is not capped by it. Raw HTML in the Markdown is shown as text, except <u> and, inside a table cell, <br>. GET /notes/{noteId} writes the note's inline images as ![alt](src). A replace keeps every image that stays in your Markdown and deletes the ones you leave out from storage, so reading a note and writing its markdown back keeps all of them. An append keeps them all. An image that points at an upload the web editor hasn't confirmed yet can still be deleted 24 hours after the upload.

Writing a note's markdown back with PUT /notes/{noteId}/content gives the same document when markdownLosses is empty. That includes an image's alt text and title, which stay text even when they look like a number, such as ![2024](x.png). An image with an empty alt or title reads back with null, and so does a link with an empty title. When it isn't, the listed parts come back simplified, as described in Markdown in notes. Block ids exist only in content, and a write gives the blocks new ones. A note whose content isn't an editor document returns markdown: null, and an append to it returns 400 with details.reason legacy-content.

Every content change raises the note's version. To avoid overwriting a save made in the app or by another client, send the version you read as baseVersion. If the note changed since, the write returns 409 conflict with details.baseVersion and details.currentVersion and writes nothing. Read the note again, merge, and retry with the new version. Without baseVersion the write always goes through.

A folderId in a body that is not one of the user's folders returns 400 invalid_request with details.field folderId and details.reason unknown-folder. It works the same on POST /notes and PATCH /notes/{noteId}.

PATCH /notes/{noteId} renames a note, moves it into a folder or out of one (folderId: null), and pins it. DELETE /notes/{noteId} moves the note to the trash, where it drops out of every read, and POST /notes/{noteId}/restore brings it back within 30 days. Both need notes:delete.

Markdown in notes

Notes use CommonMark with GFM tables and strikethrough, plus a few additions for what the editor has and plain Markdown doesn't. GET /notes/{noteId} writes them, and every write endpoint reads them.

ContentMarkdown
Task list- [ ] open and - [x] done. Every item in the list needs a checkbox, or the list stays a plain list.
Inline math$x^2$. No space after the opening $ or before the closing one, and no digit right after it, so $5 and $10 stays text.
Block math$$ on its own line, the latex, then $$ on its own line.
Mention[@Cell biology](workroad://note/<noteId>). The kind is one of note, set, subject, task, recording, test and schedule.
Underline<u>underlined</u>
Line break in a table cell<br>
Footnote[^1] in the text and [^1]: text on a line of its own. A footnote's further blocks follow, indented four spaces.
Toggle<details><summary>Summary</summary>, a blank line, the body blocks, then </details> on its own line. The summary is plain text.
CalloutA quote whose first line is only [!NOTE], [!TIP], [!WARNING] or [!DANGER]; the rest of the quote is the callout's body.
Table of contentsA [TOC] line. It is written but not read back: writing the Markdown again gives a paragraph with that text.

Headings 5 and 6 become heading 4, the deepest the editor has. Two lists next to each other alternate between - and *, because CommonMark joins two lists with the same bullet into one.

markdownLosses lists what a note's markdown can't carry. The values are:

  • empty-paragraphs: empty paragraphs are left out. One at the end of the note doesn't count, because the editor adds it back.
  • collapsed-spaces: runs of spaces become one space, and spaces at the start or end of a paragraph or next to a line break are dropped. A line break at the end of a paragraph is dropped too, and one inside a heading becomes a space.
  • image-size: image width and height are left out.
  • table-layout: merged cells, column widths and cell alignment are left out.
  • table-cell-blocks: a cell with a list, a quote, a callout, a toggle, columns, code, a formula or more than one paragraph becomes one line, with <br> between its paragraphs, headings and toggle summaries. A formula becomes inline math and code becomes its text. List bullets, checkboxes, quote and callout markers, images and embeds in the cell are left out. Footnote markers, math, citations, dates and mentions keep their usual form.
  • table-header: Markdown tables always have a header row, so the first row becomes one.
  • table-cell-background: cell background colours are left out.
  • ordered-list-type: letter and Roman numbering become numbers.
  • image-upload-state: an image that never finished uploading is written as its alt text.
  • inline-math: inline math that $…$ can't hold is written as text, such as math followed directly by a digit. Spaces at either end of the latex are trimmed.
  • mention-label: a mention without a label gets its kind as the label.
  • formatting-edges: bold, italic and strikethrough can't start or end on a space, so the space moves outside. Where Markdown can't start or end them at all, such as bold text that starts with punctuation right after a letter, the paragraph loses its bold, italic and strikethrough.
  • highlight: highlighting is dropped while its text is kept.
  • text-colour: text colour is dropped while the text is kept.
  • toggle-summary-formatting: a toggle's summary becomes plain text, so bold, italic, underline, strikethrough, links and inline code in it keep their text and lose their formatting.
  • toggle-heading-level: a toggle whose summary reads as a heading keeps the heading's text and loses its level, because a <summary> tag carries the text and nothing else.
  • code-block-language: a code block language that can't fit in a Markdown fence is left out.
  • code-block-filename: a code block's filename is left out, because a fence carries the language and nothing else.
  • code-block-wrap: a code block's soft-wrap setting is left out. The code itself is unchanged.
  • block-math: a formula line containing only $$ becomes $ $.
  • image-address: an image address is normalized, or the image becomes alt text if its address is refused.
  • image-loading: the image loading preference becomes lazy.
  • link-address: a link address is normalized, or the link becomes text if its address is refused.
  • link-title: a numeric or boolean link title comes back as that value instead of text.
  • link-attributes: the link target, rel or CSS class returns to the editor default.
  • mention-target: a mention with an invalid kind or ID becomes plain text.
  • date-chip: an inline date is written as its day in YYYY-MM-DD form, as plain text.
  • heading-level: an unsupported heading level becomes level 1 or 4.
  • ordered-list-start: a list start that Markdown can't represent becomes 1.
  • callout-kind: a callout of an unknown kind is written as a note.
  • callout-nesting: a callout directly inside another callout is written as part of the callout around it, and loses its own kind. A callout further down, inside a quote inside a callout, is written as a deeper quote and keeps its kind.
  • youtube-embed: a YouTube block is written as a link to the video; reading the Markdown back gives the link, not the player.
  • pdf-embed: a PDF block is written as a link to the file; reading the Markdown back gives the link, not the viewer.
  • recording-embed: a recording's audio player is written as its label. The recording itself isn't part of the Markdown, so reading it back gives a line of text.
  • bookmark-card: a bookmark card is written as a link, [title](address), using the page title the card shows. Its description and site line are left out, so reading the Markdown back gives the link rather than the card.
  • table-of-contents: a table of contents block is written as a [TOC] line. Its rows are the note's own headings, which the Markdown already holds, so nothing in them is lost; reading the Markdown back gives a paragraph of that text rather than the block.
  • footnote-orphan: a footnote marker with no footnote, and a footnote no marker points at, are both left out. The editor pairs them up, so this only happens to a note written through the API.
  • columns: a columns block is written as its columns one after another, left to right, without their widths. Reading the Markdown back gives those blocks in one column. In a table cell, the columns' blocks become part of the cell's one line, as table-cell-blocks describes.
  • citation-text: a citation is written as the bracketed text its style reads as, such as (Smith, 2024, p. 3), as plain text. The source behind it isn't written, so reading the Markdown back gives that text rather than the citation.
  • bibliography: a bibliography block is written as a plain bullet list of its entries, each one the reference its style reads as, in the order the note lists them. The sources behind them aren't written, so reading the Markdown back gives a list rather than the note's bibliography.
  • invalid-structure: malformed table, list or code block structure is simplified.
  • unsupported-mark: an unknown mark is dropped while its text is kept.
  • unsupported-content: an unknown node is reduced to its text.
  • exceeds-write-limit: the markdown is longer than PUT /notes/{noteId}/content accepts (100,000 characters). Append with POST /notes/{noteId}/content/append, or edit the note in the app.

Folders

A folder files notes for quick access and can sit under one of the user's subjects. GET /note-folders lists every folder the key's user owns, by name, then id. POST /note-folders creates one, and a duplicate name returns 409 conflict. Names are compared exactly, so Bio and bio are different. An icon must be one of the icons the folder picker offers, or the request returns 400 invalid_request; a folder stored with an older icon reads back as Folder.

PATCH /note-folders/{folderId} changes only the fields you send, and null on subjectId unfiles the folder. Renaming to a name another of the user's folders has returns 409 conflict, as creating one does. DELETE /note-folders/{folderId} deletes the folder and unfiles its notes rather than deleting them; their content, files and embeddings are untouched.

Folder endpoints reuse the notes:* scopes: reading a folder needs notes:view, and writing one needs the matching notes:create, notes:update or notes:delete.

Uploading files

A file upload takes three calls. The file itself goes straight to storage and never passes through the API.

  1. POST /notes/{noteId}/files with fileName, mimeType and sizeBytes. The response has the file's id, a signed upload.url and the upload.fields to send with it.
  2. POST the file to upload.url as multipart/form-data. Send every field from upload.fields in the order given, then the file itself as file, last.
  3. POST /files/{fileId}/complete. It checks that storage has the file, starts extracting its text and returns 202 with the jobId. Poll GET /files/{fileId} until status is ready or failed.
curl "$UPLOAD_URL" \
   -F "bucket=workroad" \
   -F "key=note/<noteId>/<fileId>-lecture.pdf" \
   -F "Content-Type=application/pdf" \
   -F "x-amz-date=<value>" \
   -F "x-amz-algorithm=<value>" \
   -F "x-amz-credential=<value>" \
   -F "policy=<value>" \
   -F "x-amz-signature=<value>" \
   -F "[email protected]"

Storage accepts the upload for 10 minutes after step 1 and refuses a file larger than sizeBytes, so send the exact size. Complete right after the upload, inside the same 10 minutes. The server keeps an uncompleted file for 24 hours after step 1, then deletes it and its row. Completing before the upload lands returns 409 conflict with details.reason upload-missing. Upload, then complete again. Completing checks the file's first bytes against its type, so a ZIP named notes.pdf returns 409 conflict with details.reason wrong-type, and the file is deleted.

The API takes documents up to 50 MiB: PDF, DOCX, PPTX, XLSX, ODT, ODS, ODP, RTF, EPUB, Markdown, HTML, CSV, LaTeX, Jupyter notebooks, plain text and source code. The file name's extension must match mimeType; the mimeType field of the create call lists each type. HTML, LaTeX and source code are sent as text/plain. Images are uploaded in the app.

Uploads are limited to 200 files and 2 GiB per user, and 25 files per note. Files on notes in the trash still count until permanent deletion. Over a limit, step 1 returns 409 conflict with details.reason set to files-per-user, files-per-note or bytes-per-user, plus details.limit and details.used in files or bytes. Restoring a note and deleting a file always remain available above these limits. Deleting a PDF preserves its embedded images in the note, and those images count toward the next upload's quota check. Delete files with DELETE /files/{fileId} or permanently delete trashed notes to make room.

A failed or expired file names the cause in failure. upload-expired and object-expired mean the 24 hours ran out and the upload is gone; start a new one. object-missing means storage had nothing when parsing started, unsupported-format and too-large mean the file can't be read, and not-parsed means nobody completed the upload. Completing a failed document again retries the parse. Completing a ready file returns 409 conflict with details.reason already-parsed and queues nothing. If the user already has too many jobs queued, completing returns 429 with Retry-After: 5.

GET /notes/{noteId}/files also lists images added in the app, with kind image. Only the app parses images, so completing one returns 409 conflict with details.reason image-file.

Anyone with a file's url can read the file once it is uploaded, without a key. Treat the URL as a secret.

Recordings

GET /notes/{noteId}/recordings lists a note's lecture recordings, and GET /recordings/{recordingId} returns one with its transcript, a line per entry, once it is finished. PUT /recordings/{recordingId}/summary saves a summary and keyTerms you wrote from the transcript in place of the ones the recording had, and keeps its chapters. It returns 409 with details.reason not-finished while the recording is still coming in, or summary-running while WorkRoad's AI is writing a summary.