Blanc API
Read a person's notes in Blanc and add to them, with their permission. Your app connects through Blanc's consent screen, and the person can disconnect it at any time.
Overview
- Base address:
https://api.blancnote.app. Everything lives under/v1. - For apps other people use. Every app connects with OAuth 2.0 and PKCE, and the person approves it on Blanc's consent screen. There are no API keys and no personal tokens.
- What an app can do: list and read notes as HTML, Markdown or plain text; create notes, edit them, add to them, move them to the trash and back; read notebooks and tags; and poll for everything that changed.
- JSON throughout, with camelCase fields. Timestamps are ISO 8601 in UTC with milliseconds, such as
2026-10-21T13:14:16.123Z. - The OpenAPI document is at
https://api.blancnote.app/v1/openapi.json, for code generators and API tools. The reference on this page is generated from it.
Before an app changes a note's text, Blanc keeps a version of it that the person can restore, and the API can move a note to the trash but can't delete it for good. Trash, versions and locked notes has the limits.
Register your app
Register your app in Blanc, under Settings → Connected apps → Register an app. You'll need:
- a name (up to 60 characters) and a developer name, yours or your company's (up to 100);
- one or more redirect URIs, up to 10;
- optionally a description (up to 500 characters), a homepage (an
httpsaddress), and a logo: a PNG, JPEG or WebP image of about 75 KB or less (it's stored as adata:URI, which can be at most 100 KB).
You get a client ID at once. There's no client secret: every app uses PKCE instead, so a native app has nothing to hide in its binary. An account can register up to 20 apps. Deleting an app ends every connection anyone has made to it.
Redirect URIs are where Blanc sends the person back. Each is one of:
- an
httpsaddress on any host, for a backend, such ashttps://example.com/blanc/callback; - a custom scheme your native app owns, such as
com.example.notes:/oauth. Schemes that run or read something (javascript:,data:,file:and the like), other web schemes, and Blanc's own are refused; - an
httploopback address,http://127.0.0.1/…orhttp://[::1]/…, for a CLI or a native app that listens on the device. A registered loopback address matches on any port (RFC 8252), so your app can listen wherever is free.
Apart from the loopback port, a redirect URI matches exactly: no prefixes and no wildcards. http://localhost isn't accepted, since localhost can resolve somewhere else (use 127.0.0.1 or [::1]), and neither is a fragment (#…) or a user name in the address.
Verification. Until Blanc verifies an app, its consent screen says Blanc hasn't reviewed this app above the Allow button. Verification is a label, not a gate: an unverified app works exactly the same. It covers what the consent screen shows and where codes go, so changing the app's name, developer name, logo or redirect URIs clears it. To ask for it, write to api@blancnote.app.
Connect a person's account
Blanc uses OAuth 2.0's authorization code flow with PKCE (RFC 7636), and only that: no implicit grant, no password grant, no client credentials. PKCE is required for every app, backends included.
Scopes
Ask for what your app needs. The consent screen lists each scope as a sentence, never as a scope string.
| Scope | On the consent screen | What it covers |
|---|---|---|
notes:read | “Read your notes” | Notes, notebooks, tags and the changes feed. |
notes:write | “Create and edit notes” | Create, edit, add to, trash and restore notes; includes notes:read. |
profile | “See your name” | Adds displayName to /v1/me. |
offline | “Keep access when you're not using the app” | A refresh token comes with the access token; without it, access ends after an hour. |
Most apps that write ask for notes:write offline; notes:write already includes reading.
Step by step
1. Make a PKCE verifier and challenge. The verifier is a random string of 43 to 128 characters from A–Z a–z 0–9 - . _ ~; 32 random bytes in base64url is 43. The challenge is the base64url SHA-256 of the verifier, without padding. Blanc accepts only the S256 method. Make a new pair, and a new state, every time.
2. Send the person to Blanc. Open this address in their browser (for a native app, the system browser or an authentication session, not a web view you control):
https://api.blancnote.app/v1/oauth/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https%3A%2F%2Fexample.com%2Fblanc%2Fcallback
&scope=notes%3Awrite%20offline
&state=RANDOM_STATE
&code_challenge=CHALLENGE
&code_challenge_method=S256
3. The person decides. Blanc's consent screen shows your app's name, logo and developer, what it wants to do, and the unverified line if it applies. The person signs in with their Blanc email and password on that screen, every time, then chooses Allow or Cancel. Your app never sees the password. The screen stays valid for ten minutes; after that, the person sees This page has expired and has to start again from your app.
4. Blanc sends them back to your redirect URI with code and your state, or with error=access_denied if they cancelled. Check that state is the one you sent before you do anything else.
5. Exchange the code, within ten minutes, from wherever holds the verifier. The body is form-encoded:
curl https://api.blancnote.app/v1/oauth/token \
-d grant_type=authorization_code \
-d code=THE_CODE \
-d redirect_uri=https://example.com/blanc/callback \
-d client_id=YOUR_CLIENT_ID \
-d code_verifier=THE_VERIFIER
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.…",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "3c9f0d2a8b7e61f4a05d9c2e.…",
"scope": "notes:write offline"
}
redirect_uri must be exactly the one the authorization request sent, port included. A code works once: presenting it a second time ends the person's whole connection to your app, on every device, because a second use means someone else has the code. scope says what was granted.
6. Call the API with Authorization: Bearer <access_token>. An access token lasts one hour. Treat it as opaque.
7. Refresh, if you asked for offline, when the access token expires or a call answers 401:
curl https://api.blancnote.app/v1/oauth/token \
-d grant_type=refresh_token \
-d refresh_token=THE_REFRESH_TOKEN \
-d client_id=YOUR_CLIENT_ID
Every refresh returns a new refresh token, and the one you sent stops working at once. Save the new one before you use the new access token. Sending a refresh token that was already used ends every refresh token descended from the same sign-in (access tokens already issued keep working until they expire), and there's no grace period, so an app that loses a refresh response has to send the person through the flow again. A refresh token expires 90 days after it was issued, so an app that refreshes at least once every 90 days keeps its connection. Add scope to a refresh to get an access token that can do less than the grant; you can't get more.
Refresh one at a time. Two refreshes with the same token at once count as a replay: one fails with invalid_grant, and the other's new refresh token is ended with it, so the person has to connect again. If several parts of your app can notice an expired token, make them wait for a single refresh, as the backend example below does.
8. Disconnect. For your app's own Disconnect button, revoke either token, even an access token that has expired (RFC 7009). The whole connection goes, with every token under it.
curl https://api.blancnote.app/v1/oauth/revoke -d client_id=YOUR_CLIENT_ID -d token=EITHER_TOKEN
People can also disconnect your app themselves, in Settings → Connected apps. Either way, its next call answers 401 invalid_token, and its next refresh answers invalid_grant.
A person has one connection to your app, however many times they connect. Connecting again replaces what it may do with the scopes asked for this time, so ask for everything you need each time. Connecting again without offline ends every refresh token your app holds for that person, on every device.
Example: a backend
A web app's server, in Node.js 18 or later with no dependencies; save it as connect.mjs. It keeps the verifier on the server, exchanges the code there, and keeps the tokens there. Register REDIRECT_URI exactly.
import http from "node:http";
import crypto from "node:crypto";
const BLANC = "https://api.blancnote.app";
const CLIENT_ID = process.env.BLANC_CLIENT_ID;
const REDIRECT_URI = process.env.BLANC_REDIRECT_URI; // e.g. https://example.com/blanc/callback
const pending = new Map(); // state → verifier, for the ten minutes a code lives
let tokens = null; // a real app keeps these per user, encrypted at rest
http.createServer(async (req, res) => {
const url = new URL(req.url, REDIRECT_URI);
if (url.pathname === "/blanc/connect") {
const verifier = crypto.randomBytes(32).toString("base64url");
const state = crypto.randomBytes(16).toString("base64url");
pending.set(state, verifier);
setTimeout(() => pending.delete(state), 10 * 60 * 1000);
const authorize = new URL("/v1/oauth/authorize", BLANC);
authorize.search = new URLSearchParams({
response_type: "code",
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: "notes:write offline",
state,
code_challenge: crypto.createHash("sha256").update(verifier).digest("base64url"),
code_challenge_method: "S256",
});
res.writeHead(302, { Location: authorize.href }).end();
return;
}
if (url.pathname === new URL(REDIRECT_URI).pathname) {
const state = url.searchParams.get("state");
const verifier = pending.get(state);
pending.delete(state);
if (!verifier) return void res.writeHead(400).end("That sign-in link is old or unknown. Try again.");
if (url.searchParams.has("error")) return void res.end(`Not connected (${url.searchParams.get("error")}).`);
const r = await fetch(`${BLANC}/v1/oauth/token`, {
method: "POST",
body: new URLSearchParams({
grant_type: "authorization_code",
code: url.searchParams.get("code"),
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: verifier,
}),
});
const body = await r.json();
if (!r.ok) return void res.writeHead(502).end(`Blanc said ${body.error}.`);
tokens = body;
res.end("Connected to Blanc.");
return;
}
res.writeHead(404).end();
}).listen(3000);
// Calls the API, refreshing once when the access token has expired. Callers
// that hit a 401 together share one refresh: two refreshes with the same
// token at once count as a replay, which ends the connection.
let refreshing = null;
async function blanc(path, init = {}) {
const call = () => fetch(BLANC + path, {
...init,
headers: { ...init.headers, Authorization: `Bearer ${tokens.access_token}` },
});
let res = await call();
if (res.status === 401 && tokens.refresh_token) {
refreshing ??= refresh().finally(() => { refreshing = null; });
await refreshing;
res = await call();
}
return res;
}
async function refresh() {
const r = await fetch(`${BLANC}/v1/oauth/token`, {
method: "POST",
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: tokens.refresh_token,
client_id: CLIENT_ID,
}),
});
const body = await r.json();
if (body.error === "invalid_grant") { tokens = null; throw new Error("Reconnect Blanc"); }
if (!r.ok) throw new Error(`Blanc said ${body.error}`);
tokens = body; // save the new pair before anything else: the old refresh token is dead
}
Example: a native app or CLI
A command-line tool, in Node.js 18 or later with no dependencies, that connects through a loopback redirect and adds a line to a note; save it as blanc-append.mjs. Register the redirect URI http://127.0.0.1/callback: the tool listens on any free port, and the registered address matches it. A native app works the same way, or with its own custom scheme instead of the loopback server.
#!/usr/bin/env node
// node blanc-append.mjs "text to add" [note id]
import http from "node:http";
import crypto from "node:crypto";
import { execFile } from "node:child_process";
const BLANC = "https://api.blancnote.app";
const CLIENT_ID = process.env.BLANC_CLIENT_ID;
const [text, noteId] = process.argv.slice(2);
const verifier = crypto.randomBytes(32).toString("base64url");
const state = crypto.randomBytes(16).toString("base64url");
// Listen on any free port on the loopback address.
const server = http.createServer();
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
const redirectUri = `http://127.0.0.1:${server.address().port}/callback`;
const code = await new Promise((resolve, reject) => {
server.on("request", (req, res) => {
const url = new URL(req.url, redirectUri);
if (url.pathname !== "/callback") return void res.writeHead(404).end();
res.end("Done. You can close this tab.");
server.close();
if (url.searchParams.get("state") !== state) reject(new Error("state doesn't match"));
else if (url.searchParams.has("error")) reject(new Error(url.searchParams.get("error")));
else resolve(url.searchParams.get("code"));
});
const authorize = new URL("/v1/oauth/authorize", BLANC);
authorize.search = new URLSearchParams({
response_type: "code",
client_id: CLIENT_ID,
redirect_uri: redirectUri,
scope: "notes:write",
state,
code_challenge: crypto.createHash("sha256").update(verifier).digest("base64url"),
code_challenge_method: "S256",
});
console.log(`Opening ${authorize.href}`);
const opener = { darwin: "open", win32: "explorer" }[process.platform] ?? "xdg-open";
execFile(opener, [authorize.href]);
});
const tokenRes = await fetch(`${BLANC}/v1/oauth/token`, {
method: "POST",
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: redirectUri, // exactly as sent, port included
client_id: CLIENT_ID,
code_verifier: verifier,
}),
});
const tokens = await tokenRes.json();
if (!tokenRes.ok) throw new Error(`${tokens.error}: ${tokens.error_description}`);
// Add to the note given, or make a new one.
const res = await fetch(BLANC + (noteId ? `/v1/notes/${encodeURIComponent(noteId)}/append` : "/v1/notes"), {
method: "POST",
headers: {
Authorization: `Bearer ${tokens.access_token}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({ markdown: text }),
});
const note = await res.json();
if (!res.ok) throw new Error(`${note.error}: ${note.message}`);
console.log(`${note.title} ${note.url}`);
When connecting fails
Before Blanc trusts your request, it can't send anything back to you, so the person sees a Blanc page saying the link can't connect an app. That happens when client_id or redirect_uri is missing, the app is unknown or turned off, or the redirect URI isn't one the app registered. Blanc never redirects to an address it hasn't checked. Wrong passwords on the consent screen are limited to 20 every 15 minutes from one address; past that, the person sees Too many attempts.
After that, problems go back to your redirect URI as error, error_description and your state:
error |
Why |
|---|---|
access_denied |
The person chose Cancel. |
invalid_request |
A parameter was sent twice, state is over 1,024 characters, or code_challenge or code_challenge_method is missing or wrong. |
unsupported_response_type |
response_type isn't code. |
invalid_scope |
scope is empty or names a scope Blanc doesn't have. |
The token and revoke endpoints answer errors as RFC 6749 describes, { "error": "…", "error_description": "…" }:
error |
Status | Why |
|---|---|---|
invalid_request |
400, or 413 for a form body over 16 KB | grant_type is missing, a code exchange is missing code or redirect_uri, a revoke is missing token, or the body can't be read. |
invalid_client |
401 | client_id is missing or unknown, or, at the token endpoint, the app is turned off. A turned-off app can still revoke. |
invalid_grant |
400 | The code or refresh token is missing, malformed, revoked, expired, already used, or was issued to another app; or code_verifier or redirect_uri doesn't match the authorization request. A wrong verifier or redirect URI doesn't use up the code. |
unsupported_grant_type |
400 | grant_type isn't authorization_code or refresh_token. |
invalid_scope |
400 | A refresh asked for a scope Blanc doesn't have, or more than the person granted. |
server_error |
500 | Something went wrong at Blanc. Try again later. |
A refresh that fails with invalid_grant means the person has to connect again. That's a promise: a revoked, expired or already-used refresh token always answers invalid_grant, and retrying can't help, so show a Reconnect button.
Making requests
Send the access token on every call:
curl https://api.blancnote.app/v1/me -H "Authorization: Bearer $TOKEN"
{
"userId": "LXEWQrcmsEQBYnyp-6wy9chTD7GQPMTbAiWHF5IaSIE",
"app": { "clientId": "blanc_Xq3vN8rTkL2pW9yZb4cD1e", "name": "Morning" },
"scopes": ["notes:write", "offline"]
}
GET /v1/me is the "is my token working" call, and any scope will do. userId is how your app tells its users apart: it's stable for one account as your app sees it, and different for every app, so apps can't match up their users. With the profile scope it adds displayName, when the account has a name or a nickname.
- Writes send JSON, with
Content-Type: application/json. - Unknown fields and parameters are refused. The notes, notebooks and tags endpoints answer an unknown query parameter or body field, or a parameter sent twice, with a
400, so a misspelt filter fails loudly instead of quietly returning everything. - Ids are opaque strings. A note's id looks like
note-1792588456123-k3x9; don't parse it. - Successful resource responses aren't cacheable (
Cache-Control: private, no-store).
Notes
Title and body
A note in Blanc is a title heading, then everything else. The API keeps the two apart: title is the heading's text, and content is the body, everything after the heading. Writing title changes only the heading's text; writing markdown or html changes only the body.
- A title is one line: runs of spaces and line breaks become single spaces, and it can be up to 1,000 characters. An empty title makes the note Untitled.
- Creating a note without
title: a body that opens with a level-1 heading (# Saturday 24 October) uses it as the title. Otherwise the note is Untitled. - Changing the title rewrites the heading's text and drops any formatting or icon inside it. Sending a note's title back unchanged changes nothing: titles are compared with their whitespace collapsed, as a write collapses it, so one with a double space is left as it was too.
previewis the start of the text after the title, up to 120 characters, as the note list shows it.urlis the note's address on the web,https://open.blancnote.app/n/<id>, for showing and sharing; it lands on a page at open.blancnote.app that sends the browser on to the note'sblanc://note/<id>link.appUrlis that link itself, for native apps that would rather skip the browser.
All of this assumes the note opens with a level-1 heading, as every note made in Blanc does. If the person has turned the first line into something else, such as a paragraph or a smaller heading, there's no title heading to keep apart: title is still that first line's text, but content.html starts with it, writing the body replaces it (and so changes the title), and writing title adds a new heading above it.
Reading: HTML, Markdown and plain text
GET /v1/notes/{id} returns the note with content.html. Add format for other renderings:
curl "https://api.blancnote.app/v1/notes/note-1792588456123-k3x9?format=markdown,text" \
-H "Authorization: Bearer $TOKEN"
content.htmlis the body exactly as Blanc stores it, Blanc'sdata-attributes and all. Write it back unchanged and nothing is lost, live blocks included, unless its text has a tab or a line break outside a code block (from a pasted spreadsheet, say). Then it counts as HTML formatted for reading, and its whitespace collapses (see what happens to HTML).content.markdownandcontent.textare the renderings Blanc's own exports make, for showing a note in an app that doesn't render HTML.- Every write returns the note as well, and takes
formatthe same way.
Writing: send Markdown
Send Markdown unless you know why you need HTML. Plain text is Markdown. Blanc reads GitHub-flavoured Markdown: headings, emphasis, links (bare URLs too), lists, quotes, code, tables, strikethrough and horizontal rules.
- A single line break doesn't start a new line.
MilkandEggson two lines become one paragraph, Milk Eggs. Leave a blank line between paragraphs, end a line with two spaces or a backslash for a break within one, or send the lines as a list (- Milk). - Task lists become Blanc checklists.
- [ ] Milkis an unticked item and- [x] Eggsa ticked one. A list converts only when every item in it is a task; a list that mixes tasks and bullets stays a bulleted list, and a numbered task list (1. [ ] Milk) stays numbered. - Images can be web addresses or
data:URLs. Inline images count toward the 10 MB limit on a note write.
Everything you send is cleaned before it's stored, by the rules below, whether it arrived as Markdown or as HTML.
What happens to HTML
Blanc keeps what its editor can show and drops anything that could run code.
- Kept: paragraphs, headings, lists and checklists, block quotes, code blocks and inline code, horizontal rules, line breaks, tables and their column groups, figures and captions, links and images; bold, italic, underline, strikethrough, highlight (
mark), subscript and superscript;div,spanandlabel; checkboxes, the only inputs a note has; and the attributes Blanc's own content is made of:class,style, everydata-attribute, and the media attributes Blanc's editor writes (src,name,size,mime,url,provider,videoid,displaymode,attachmentid). - Removed, with what's inside them: scripts, style sheets, iframes, objects and embeds, SVG and MathML, and form fields such as text areas and menus.
- Removed, keeping their text: every other element, such as
section,formorbutton. - Removed attributes: event handlers such as
onclick; links in any scheme buthttp,https,mailto,teland Blanc's ownblancandblanc-note(the link's text stays); astylethat loads or runs something (url(,expression(,@import, or a backslash); a video'surlunless it'shttporhttps, and itsproviderunless Blanc knows it. - Images and files load only from
http,httpsordata:addresses. Adata:URL or a file'smimethat a browser would run, such as HTML, SVG, XML or script, becomesapplication/octet-stream. - Whitespace is kept exactly as you send it, double spaces included, unless the HTML has line breaks or tabs in its text outside
pre. That's HTML formatted for reading, and its whitespace collapses as a browser would show it. - Loose text outside any element becomes a paragraph.
Live blocks
A Blanc note can hold a live Todoist list. Its tasks come from Todoist on the person's devices and aren't stored in the note, so the API can't show them. Instead:
content.htmlhas the block's wrapper,<div data-todoist-project-block …>, with its attributes, so writing that HTML back keeps the live list.content.markdownhas<!-- blanc: live Todoist block -->where the list sits.content.texthas[Live Todoist list: <project name>], or[Live Todoist list]when it has no name.
The markers are placeholders. Writing the Markdown or text back replaces the live list with nothing, or with a line of text. To edit a note that has one, write html, or use append, which leaves the rest of the note as it was.
An Index block, a live list of notes by tag, notebook or smart folder, is stored with its latest results, so it reads as a bulleted list of those notes' titles, linked to them in HTML and Markdown.
Adding to a note
POST /v1/notes/{id}/append adds blocks after the note's last block that has something in it. With heading, they go at the end of the section under the first level-2 heading that reads the same, ignoring case and extra spaces; a section runs to the next level-1 or level-2 heading. Only headings in the note's main flow count, not one nested inside another block. If there's no such heading, Blanc adds it at the end of the note, with the blocks under it.
curl -X POST https://api.blancnote.app/v1/notes/note-1792588456123-k3x9/append \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2a90-5c1e-4c5f-9a8e-0d3b2f7c4e11" \
-d '{ "markdown": "- 09:41 Called Amy about Saturday", "heading": "Log" }'
Append is the safest way to add to a note. It reads the note and writes it back in one step, so it builds on the latest version Blanc has rather than on a copy your app read earlier, and two appends never lose each other's blocks. It can't protect against an edit on one of the person's devices at the same moment, though: Blanc saves a note whole, and the newer save wins. An app keeping a daily log can create the note once, keep its id, and append through the day. If it loses the id, GET /v1/notes?title=… finds the note by its exact title.
Editing safely with If-Match
PATCH /v1/notes/{id} replaces the whole body. If the person changed the note after you read it, a patch without a condition wins, just as an edit from another of their devices would. The version Blanc keeps means nothing is lost, but they'd have to go looking for it.
So send the ETag of the note you read as If-Match:
curl -X PATCH https://api.blancnote.app/v1/notes/note-1792588456123-k3x9 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H 'If-Match: "2026-10-23T08:02:51.456Z"' \
-d '{ "markdown": "## Morning\n\nWalked to the river before work." }'
The ETag is the note's updatedAt in quotes; the bare timestamp works too, and * means "as long as the note exists". Send one value: a weak (W/…) tag or a list is a 400. If the note has changed, nothing is written and the answer is 412 precondition_failed, with the note's currentUpdatedAt, even when the note is now in the trash. Read it again, apply your change to what's there now, and try again. A patch that changes nothing writes nothing.
Retrying safely with Idempotency-Key
Networks fail after a write lands, and a retry shouldn't make a second note or add a line twice. Send an Idempotency-Key on create and append: any 1 to 255 visible ASCII characters you make up for that one operation, such as a UUID from your queue. Retry with the same key and exactly the same body.
- For 24 hours, a request with a key your app already used for this person writes nothing. It answers with the first request's status, the note as it is now, and
Idempotent-Replayed: true. - Only requests that succeeded count, so a request that failed can be retried with its key.
- The same key with a different request is
422 idempotency_key_reused. The same request means the same method, path and JSON body: the same fields with the same values, in the same order. Spacing doesn't matter, and the query string isn't part of it. - A key belongs to your app and this person, whatever the endpoint or the note, so use a fresh one for every operation.
- If the note it made has since been deleted for good, a replay answers
404.
Trash, versions and locked notes
- Trash.
DELETE /v1/notes/{id}moves a note to the trash, as if the person had deleted it, and clears its pins. The trash empties after 30 days, the next time one of the person's devices opens Blanc; until thenPOST /v1/notes/{id}/restorebrings the note back to its notebooks and tags, unpinned. Notes in the trash can be read, but writing to one answers409 in_trash: restore it first. The API can't delete a note for good. - Versions. Before a patch changes a note's title or body, Blanc keeps a version labelled Before ‹your app› edited it. Before an append it keeps one labelled Before ‹your app› added to it, at most once every ten minutes for each note, and none for a note over 1 MB. They appear in the note's history, where the person can restore them. Each device keeps the newest 15 versions apps made for a note apart from the person's own, so your app's versions never push out the history the person made; an app that edits one note many times pushes out only its own older versions. Changing a note's notebooks, tags or pin keeps no version. The API can't read or change versions.
- Locked notes. Blanc doesn't lock notes yet. When it does, a locked note will list with
locked: true, an emptypreviewandcontent: null, and every write to it will answer409 locked. No scope unlocks a note. When a write comes backlocked, hold your change and tell the person the note is locked in Blanc; don't make a second, unlocked copy beside it.
Notebooks and tags
GET /v1/notebooks and GET /v1/tags each return the whole list in one page. Notebooks come flat, sorted so that the notebooks inside any one parent are in the sidebar's order; rebuild the tree from parentId. Tags come in the sidebar's order. Both carry archived: things the person put away, which a picker would usually hide.
To file a note, send notebookIds and tags on create or patch; each replaces the note's whole set. A note's notebookIds and tags leave out notebooks and tags in the trash, as Blanc does, so you can send back what you read.
- Notebooks must exist, archived ones included. An unknown id is a
404that names it. Apps file notes into the person's notebooks; they don't make them. - Tags are made as needed, as typing a new tag in Blanc does. Names are stored trimmed, in lower case, with spaces as hyphens:
Daily Notesbecomesdaily-notes. Naming a tag the person deleted brings it back. - Apps can't create, rename or delete notebooks, or rename or delete tags.
Polling for changes
There are no webhooks in v1. Instead, GET /v1/notes?since=… is a feed of the notes that changed, made for polling from a server or a device.
- Start from the beginning to copy everything and then keep up:
GET /v1/notes?since=1970-01-01T00:00:00Zreturns every note, trashed ones included, and a note that changes while you page through comes again later, so nothing slips between your copy and the feed. Or start from a time of your choosing, from before you begin any copy of your own, and a little early:sinceis compared with Blanc's clock, not yours, and a time in Blanc's future returns empty pages until it arrives. Write a+in an offset as%2B. - Read every page. While
hasMoreis true, ask again withcursorset tonextCursor, and nothing else butlimit. - Keep the last
nextCursor. It's never null in a feed. Poll with it later:GET /v1/notes?cursor=….
The feed returns note summaries, the oldest change first, including your own app's writes. A note that moved to or from the trash comes as a summary with deletedAt set or cleared. A note deleted for good comes as { "id": …, "purged": true, "purgedAt": … }, so your copy never outlives it; one emptied from the trash arrives when one of the person's devices opens Blanc after its 30 days. Summaries have no body: read the note for that. A feed takes no filters, because it reports every note that changes; filter the items yourself.
What the feed doesn't show. A note appears when the note itself changes. Deleting a notebook or tag for good, or renaming a tag on a device, changes the notebookIds or tags of the notes involved without those notes appearing, so read the notebook and tag lists again when you need them current.
// Returns the cursor to poll with next time.
async function poll(cursor, since) {
let query = cursor ? `cursor=${encodeURIComponent(cursor)}` : `since=${encodeURIComponent(since)}`;
for (;;) {
const page = await (await blanc(`/v1/notes?${query}&limit=100`)).json();
for (const item of page.data) {
if (item.purged) forget(item.id);
else remember(item); // a summary; deletedAt says whether it's in the trash
}
if (!page.hasMore) return page.nextCursor;
query = `cursor=${encodeURIComponent(page.nextCursor)}`;
}
}
The feed runs a second or more behind. It reads only up to the start of the oldest database transaction still open, anyone's, less a second, and never more than about five minutes back. That way it doesn't hand out a cursor past a change that hasn't finished saving; only a write still saving after five minutes could be passed. In practice a change appears a second or two after it's made, and later only while a long transaction is still running. A change your app just made may come on the next poll rather than this one. When nothing has settled since your cursor, you get an empty page and a cursor to keep.
Poll as often as your app needs, within the rate limits: every few minutes is plenty for most.
Rate limits
| Reads | Writes | |
|---|---|---|
| Each app, acting for each person | 600 per 5 minutes | 120 per 5 minutes |
| Each app, across everyone it acts for | 6,000 per 5 minutes | 1,200 per 5 minutes |
Reads are GET /v1/me and the GET calls on notes, notebooks and tags. Writes are creating, editing, appending to, trashing and restoring notes. Every request with a valid token counts, whatever its answer, errors included; a request without one isn't counted, and neither are the OAuth endpoints or the OpenAPI document. Over a limit, the answer is 429 rate_limited: wait the number of seconds in Retry-After, then try again. Responses also carry RateLimit and RateLimit-Policy headers in the format of the IETF's draft standard, with an entry for each limit the request counts against (reads-per-user and reads-per-app, or the same for writes), so your app can pace itself before it's refused.
A note write's body can be up to 10 MB, and a form post to the OAuth endpoints up to 16 KB.
Errors
Every error from a resource endpoint has the same shape. Switch on error, which never changes; message is for people reading logs, and its wording can.
{ "error": "in_trash", "message": "The note is in the trash. Restore it, then write to it." }
| error | Status | Meaning |
|---|---|---|
invalid_request | 400 | The request can't be read: a malformed body, an unknown field or parameter, a bad cursor. A body in a charset Blanc doesn't read is a 415. |
invalid_token | 401 | No access token, or an expired or revoked one (RFC 6750). |
insufficient_scope | 403 | The token is good but lacks the scope this needs (RFC 6750). |
not_found | 404 | No such note or endpoint. Another account's note is a 404 too, never a 403. |
locked | 409 | The note is locked. No note is locked until Blanc's Locked Notes arrives. |
in_trash | 409 | The note is in the trash. Restore it, then write to it. |
precondition_failed | 412 | If-Match no longer matches: the note changed after you read it. |
payload_too_large | 413 | The body is over the 10 MB limit. |
idempotency_key_reused | 422 | The same Idempotency-Key was used for a different request. |
rate_limited | 429 | Too many requests. Wait for Retry-After. |
server_error | 500 | Something went wrong at Blanc. |
- A
401carriesWWW-Authenticate: Bearer realm="blanc", error="invalid_token", or justBearer realm="blanc"when no token was sent or theAuthorizationheader isn'tBearer <token>(RFC 6750). Refresh the token; if the refresh saysinvalid_grant, the person has to connect again. A token also stops working when the person connects your app again with fewer scopes, or when Blanc turns the app off. - A
403carrieserror="insufficient_scope"and thescopethe call needs. - A
412addscurrentUpdatedAt, the note'supdatedAtnow. - An address the API doesn't have is
404 not_found.
Errors from the OAuth endpoints use RFC 6749's shape instead; see when connecting fails.
Versions and deprecation
This is version 1, and everything lives under /v1.
- Adding a field or an optional parameter isn't a breaking change, and needs no notice. Write your app to ignore fields it doesn't know.
- Breaking changes, such as removing or renaming a field or changing its type, ship as
/v2./v1keeps working for 12 months after/v2arrives.
Limits
| Note write body | 10 MB |
| OAuth form body | 16 KB |
| Title | 1,000 characters |
Append heading |
500 characters |
| Tags on a note | 100, each up to 100 characters |
| Notebooks a note is in | 100 |
| Page size | 1 to 100; 50 if you don't say |
Idempotency-Key |
1 to 255 visible ASCII characters, remembered 24 hours |
state |
1,024 characters |
| Authorization code | 10 minutes, one use |
| Access token | 1 hour |
| Refresh token | 90 days from when it's issued; a new one on every refresh |
| Redirect URIs per app | 10 |
Lengths count UTF-16 code units, as JavaScript's length does: most characters count as one, and an emoji as two.
Not in v1
- Webhooks. Poll the changes feed.
- Calls from a web page. Apart from the OpenAPI document,
/v1doesn't let pages on other sites read its responses, so a browser can't call it directly. Call it from your server. - Full-text search.
qmatches titles only. - Attachments. There's no upload; images travel in the body as addresses or
data:URLs. - Archiving and pins inside notebooks.
archivedis read-only, andpinnedis the pin that tops every list. - Version history, permanent deletion, and changing notebooks or tags other than filing a note in them.
Reference
Generated from the OpenAPI document.
OAuth
Connecting your app to a user's Blanc account, and disconnecting it.
Ask the user to connect your app
GET /v1/oauth/authorize No token.
Send the user's browser here; never call it from code. Blanc shows its consent screen: your app's name, logo and developer, what the app wants to do in plain words, and the line Blanc hasn't reviewed this app unless the app is verified. The user signs in with their Blanc email and password on that screen, every time, and chooses Allow or Cancel. Your app never sees the password. Wrong passwords are limited to 20 every 15 minutes from one address.
On Allow, Blanc redirects to redirect_uri with code and state. The code lasts ten minutes and works once. On Cancel, it redirects with error=access_denied. A request with a problem your app should hear about redirects with error and error_description: invalid_request (a parameter sent twice, a state over 1,024 characters, or a missing or malformed PKCE challenge), unsupported_response_type, or invalid_scope. state comes back on every redirect you sent one with.
When the app itself or its redirect URI can't be trusted, Blanc shows the user an error page instead of redirecting, so it can never be used as an open redirect: a missing, unknown or disabled client_id, or a redirect_uri the app never registered.
Parameters
| Name | In | Type | About |
|---|---|---|---|
response_type | query | code | Always code. |
client_id | query | string | Your app's client id. |
redirect_uri | query | string | One of your app's registered redirect URIs, matched exactly. The one exception is a registered loopback address, http://127.0.0.1/… or http://[::1]/…, which matches on any port (RFC 8252), so a native app or CLI can listen on whatever port is free. localhost gets no such exception, and a fragment is never allowed. |
scope | query | string | One or more scopes, separated by spaces. Ask for what you need: the consent screen lists each one in plain words. |
state optional | query | string | A value only your app can predict, returned unchanged. Check it when the user comes back, to know the response belongs to a request you made. At most 1,024 characters. |
code_challenge | query | string | PKCE (RFC 7636): the base64url SHA-256 of your code verifier, without padding. Required for every app. |
code_challenge_method | query | S256 | Always S256. plain isn't accepted. |
Responses
200 | The consent screen. An HTML page. |
302 | Back to redirect_uri with error, error_description and state, because the request has a problem your app should hear about. Headers: Location. |
400 | An error page for the user, shown instead of redirecting: client_id or redirect_uri is missing, the app is unknown or disabled, or the redirect URI isn't registered. An HTML page. |
500 | An error page for the user: something went wrong at Blanc. An HTML page. |
Get tokens
POST /v1/oauth/token No token.
Exchange an authorization code for tokens, or refresh them. Send the body form-encoded, as RFC 6749 says, with your client_id. There is no client secret: PKCE is what proves the exchange comes from the app that started the flow.
An access token lasts one hour. A refresh token comes only when the grant includes offline, and every refresh returns a new one. Store it before you use the new access token: the one you sent stops working at once. Sending a refresh token that has already been used ends that whole chain of refresh tokens (access tokens already issued work until they expire), and the user has to connect again. There is no grace period, so an app that loses a refresh response has to reconnect, and two refreshes with the same token at once count as a replay: refresh one at a time. Each refresh token expires 90 days after it was issued, so an app that refreshes at least once every 90 days keeps its connection.
invalid_grant on a refresh means connect again. A refresh token that is revoked, expired or already used always answers 400 with invalid_grant; show the user a way to reconnect, since retrying won't help. A code exchange answers invalid_grant for a code that is revoked, expired, used or issued to another app, and for a missing or wrong code_verifier or redirect_uri, which doesn't use up the code. A code presented a second time ends the user's whole connection to the app, because a second use means someone else has the code.
Request body
Form-encoded (application/x-www-form-urlencoded), one of:
Exchange the code your redirect URI received.
| Field | Type | About |
|---|---|---|
grant_type | "authorization_code" | |
code | string | |
redirect_uri | string | Exactly the redirect_uri the authorization request sent, port included. |
client_id | string | |
code_verifier | string | The PKCE verifier whose SHA-256 you sent as code_challenge: 43 to 128 characters of letters, digits and -._~. |
Swap a refresh token for a new access token and a new refresh token.
| Field | Type | About |
|---|---|---|
grant_type | "refresh_token" | |
refresh_token | string | |
client_id | string | |
scope optional | string | Optional: a narrower set of the granted scopes, for an access token that can do less. Leave it out to keep them all. |
Responses
200 | Tokens. OAuthToken. Headers: Cache-Control. |
400 | invalid_request (grant_type is missing, a code exchange is missing code or redirect_uri, or the body can't be read), invalid_grant (see above; also a missing or malformed refresh_token), unsupported_grant_type, or invalid_scope (a refresh asked for a scope Blanc doesn't have, or more than the user granted). OAuthError. |
401 | invalid_client: client_id is missing or unknown, or the app is disabled. OAuthError. |
413 | invalid_request: a form body over 16 KB. OAuthError. |
500 | server_error: the request may be fine; try again later. OAuthError. |
Disconnect
POST /v1/oauth/revoke No token.
Ends the user's connection to your app (RFC 7009), for an app's own Disconnect button. Send either of your tokens, even an access token that has expired: whichever you send, the whole grant goes, and every token issued under it stops working on its next request. The answer is 200 with an empty body whether or not the token meant anything, so this endpoint can't be used to test tokens. A disabled app can still revoke.
Request body
Form-encoded (application/x-www-form-urlencoded): RevokeRequest.
Responses
200 | Done, or the token meant nothing. The body is empty. |
400 | invalid_request: token is missing, or the body can't be read. OAuthError. |
401 | invalid_client: client_id is missing or unknown. OAuthError. |
413 | invalid_request: a form body over 16 KB. OAuthError. |
500 | server_error. OAuthError. |
Identity
Who a token acts for.
Who the token acts for
GET /v1/me Any scope.
The "is my token working" call, and how your app tells its users apart. Any scope will do. userId is stable for one account as your app sees it, and different for every app, so it can't be used to match users across apps. displayName comes only with the profile scope.
Responses
200 | The account and the app, as this token sees them. Me. |
401 | No access token, or one that has expired, been revoked, or no longer fits the user's grant. Refresh the token; if the refresh fails with invalid_grant, the user has to connect again. ApiError. Headers: WWW-Authenticate. |
429 | Over the rate limit: 600 reads and 120 writes per 5 minutes for each app acting for each user, and ten times that for each app across all its users. Wait the number of seconds in Retry-After. ApiError. Headers: Retry-After, RateLimit, RateLimit-Policy. |
500 | Something went wrong at Blanc. The request may be fine; try again later. ApiError. |
Notes
The notes themselves: list, read, create, edit, add to, trash and restore. The API can't delete a note for good.
List notes, or the changes since a time
GET /v1/notes Needs notes:read.
Two modes.
A list, without since: note summaries, the most recently changed first. The trash is left out unless deleted=true, which lists only the trash. Archived notes are included unless you filter on archived. Filters combine. For the next page, send the same filters again with cursor.
A changes feed, with since: every note that changed after that time, the oldest change first. It includes notes moved to or from the trash, and notes deleted for good, as PurgedNote items, so a copy your app keeps never outlives the note. A feed takes no filters, because it reports every note that changes; filter the items yourself. A note appears when it changes itself: deleting a notebook or tag for good, or renaming a tag, changes the filing of the notes involved without putting them in the feed. For the next page, or the next poll, send only cursor (and limit, if you like). nextCursor is never null in a feed: store it and poll with it later. The feed runs a second or more behind writes, so a change you just made may arrive on the next poll. since=1970-01-01T00:00:00Z starts it at the beginning: every note, so a copy and the feed never miss each other.
Cursors are opaque, and a list's cursor and a feed's cursor don't mix.
Parameters
| Name | In | Type | About |
|---|---|---|---|
notebook optional | query | string | Only notes filed in this notebook, by id. Notes in its sub-notebooks are not included. |
tag optional | query | string | Only notes with this tag. Tags are stored trimmed, in lower case, with spaces as hyphens, and the filter is cleaned the same way, so Daily Notes finds daily-notes. |
pinned optional | query | boolean | Only pinned notes, or only unpinned ones. This is the pin that tops every list a note is in. |
archived optional | query | boolean | Only archived notes, or only notes that aren't archived. Leave it out for both. |
deleted optional | query | boolean | true lists only the trash. false, like leaving it out, lists everything but the trash. |
title optional | query | string | Only notes with exactly this title, case and all. How to find a note your app made when it has lost the note's id, such as a daily note titled by date. |
q optional | query | string | Only notes whose title contains this text, ignoring case. Titles only: the API has no full-text search. |
since optional | query | timestamp | Switches to the changes feed: every note changed after this time, by Blanc's clock. ISO 8601 with Z or an offset, such as 2026-10-21T09:00:00Z. In a URL, write a + in an offset as %2B. |
cursor optional | query | string | The previous page's nextCursor. |
limit optional | query | integer | Items per page. Default 50. 1 to 100. |
Responses
200 | A page of the list (NotePage) or of the feed (NoteFeedPage). NotePage or NoteFeedPage. |
400 | An unknown parameter, a parameter sent twice, a malformed value or cursor, a filter on a feed, or a list cursor sent with since. ApiError. |
401 | No access token, or one that has expired, been revoked, or no longer fits the user's grant. Refresh the token; if the refresh fails with invalid_grant, the user has to connect again. ApiError. Headers: WWW-Authenticate. |
403 | The token is good, but its scopes don't include the one this needs. WWW-Authenticate names it. Connect again asking for it. ApiError. Headers: WWW-Authenticate. |
429 | Over the rate limit: 600 reads and 120 writes per 5 minutes for each app acting for each user, and ten times that for each app across all its users. Wait the number of seconds in Retry-After. ApiError. Headers: Retry-After, RateLimit, RateLimit-Policy. |
500 | Something went wrong at Blanc. The request may be fine; try again later. ApiError. |
Create a note
POST /v1/notes Needs notes:write.
Send the body as markdown or html, never both. Send Markdown unless you know why you need HTML: plain text is valid Markdown. Without a title, a body that opens with a level-1 heading (# Like this) uses it as the title; otherwise the note is titled Untitled.
Tags that don't exist yet are created, as typing a new tag in Blanc does. Notebooks must already exist: an unknown notebook id is a 404, because filing is the user's to arrange.
Send an Idempotency-Key so a retry can't create the note twice.
Parameters
| Name | In | Type | About |
|---|---|---|---|
format optional | query | list of html or markdown or text | Which renderings of the body to include, separated by commas, such as markdown,text. html always comes. |
Idempotency-Key optional | header | string | A key you make up for this one operation, such as a UUID from your queue, sent again unchanged on every retry. For 24 hours, a request with a key this app already used for this user writes nothing and answers as the first one did, with the note as it is now. Only requests that succeeded count, so a failed request can be retried with the same key. The same key with a different request is a 422: the same request means the same method, path and JSON body, fields in the same order. A key is this app's for this user across every endpoint and note, so use a fresh one for each operation. At most 255 characters. |
Request body
JSON: NoteInput.
Responses
201 | Created. A replay of an earlier request with the same Idempotency-Key also answers 201, with Idempotent-Replayed: true and the note as it is now. Note. Headers: Location, ETag, Idempotent-Replayed. |
400 | The request can't be read: a body that isn't a JSON object or isn't sent as application/json, an unknown field or parameter, a parameter sent twice, or a value of the wrong type or out of range. message says which. ApiError. |
401 | No access token, or one that has expired, been revoked, or no longer fits the user's grant. Refresh the token; if the refresh fails with invalid_grant, the user has to connect again. ApiError. Headers: WWW-Authenticate. |
403 | The token is good, but its scopes don't include the one this needs. WWW-Authenticate names it. Connect again asking for it. ApiError. Headers: WWW-Authenticate. |
404 | A notebook in notebookIds doesn't exist (message names it), or this is a replay and the note it made has since been deleted for good. ApiError. |
413 | The body is over the 10 MB limit for note writes. ApiError. |
422 | This Idempotency-Key was used in the last 24 hours for a different request. Nothing was written. ApiError. |
429 | Over the rate limit: 600 reads and 120 writes per 5 minutes for each app acting for each user, and ten times that for each app across all its users. Wait the number of seconds in Retry-After. ApiError. Headers: Retry-After, RateLimit, RateLimit-Policy. |
500 | Something went wrong at Blanc. The request may be fine; try again later. ApiError. |
Read a note
GET /v1/notes/{id} Needs notes:read.
The note and its body. content.html always comes; ask for Markdown or plain text with format. Notes in the trash can be read too.
Parameters
| Name | In | Type | About |
|---|---|---|---|
id | path | string | The note's id, URL-encoded. |
format optional | query | list of html or markdown or text | Which renderings of the body to include, separated by commas, such as markdown,text. html always comes. |
Responses
200 | The note. Note. Headers: ETag. |
400 | The request can't be read: a body that isn't a JSON object or isn't sent as application/json, an unknown field or parameter, a parameter sent twice, or a value of the wrong type or out of range. message says which. ApiError. |
401 | No access token, or one that has expired, been revoked, or no longer fits the user's grant. Refresh the token; if the refresh fails with invalid_grant, the user has to connect again. ApiError. Headers: WWW-Authenticate. |
403 | The token is good, but its scopes don't include the one this needs. WWW-Authenticate names it. Connect again asking for it. ApiError. Headers: WWW-Authenticate. |
404 | No such note. A note in another account is a 404 too, never a 403. ApiError. |
429 | Over the rate limit: 600 reads and 120 writes per 5 minutes for each app acting for each user, and ten times that for each app across all its users. Wait the number of seconds in Retry-After. ApiError. Headers: Retry-After, RateLimit, RateLimit-Policy. |
500 | Something went wrong at Blanc. The request may be fine; try again later. ApiError. |
Edit a note
PATCH /v1/notes/{id} Needs notes:write.
Send any of the create fields; anything you leave out stays as it is. markdown or html replaces the whole body. title replaces the title's text: the heading keeps its attributes but loses any formatting inside it, and sending the title back as it was changes nothing (titles compare with their whitespace collapsed). notebookIds and tags each replace the note's whole set. A patch that changes nothing writes nothing.
Before a patch changes the title or body, Blanc keeps a version of the note as it was, labelled Before ‹your app› edited it, which the user can restore.
To add to a note, use append instead: it builds on the latest version Blanc has, not on the copy you read. When you do replace the body, send If-Match with the updatedAt you read. A 412 means the note changed since, and carries currentUpdatedAt.
A note in the trash can't be edited: restore it first.
Parameters
| Name | In | Type | About |
|---|---|---|---|
id | path | string | The note's id, URL-encoded. |
format optional | query | list of html or markdown or text | Which renderings of the body to include, separated by commas, such as markdown,text. html always comes. |
If-Match optional | header | string | Make the patch conditional: the note's updatedAt as its ETag gives it, quotes and all ("2026-10-21T13:14:16.123Z"), or bare, or * for "as long as the note exists". One value only: a weak (W/…) tag or a list is a 400. If the note has changed since, nothing is written and the answer is 412. |
Request body
JSON: NoteInput.
Responses
200 | The note as it is now. Note. Headers: ETag. |
400 | The request can't be read: a body that isn't a JSON object or isn't sent as application/json, an unknown field or parameter, a parameter sent twice, or a value of the wrong type or out of range. message says which. ApiError. |
401 | No access token, or one that has expired, been revoked, or no longer fits the user's grant. Refresh the token; if the refresh fails with invalid_grant, the user has to connect again. ApiError. Headers: WWW-Authenticate. |
403 | The token is good, but its scopes don't include the one this needs. WWW-Authenticate names it. Connect again asking for it. ApiError. Headers: WWW-Authenticate. |
404 | No such note, or a notebook in notebookIds doesn't exist (message names it). ApiError. |
409 | in_trash: the note is in the trash, so restore it first. locked: the note is locked, and an app can never write to it. No note is locked yet; locking arrives with Blanc's Locked Notes. ApiError. |
412 | If-Match no longer matches: the note changed after you read it. Nothing was written. Read it again from currentUpdatedAt. PreconditionFailed. |
413 | The body is over the 10 MB limit for note writes. ApiError. |
429 | Over the rate limit: 600 reads and 120 writes per 5 minutes for each app acting for each user, and ten times that for each app across all its users. Wait the number of seconds in Retry-After. ApiError. Headers: Retry-After, RateLimit, RateLimit-Policy. |
500 | Something went wrong at Blanc. The request may be fine; try again later. ApiError. |
Move a note to the trash
DELETE /v1/notes/{id} Needs notes:write.
The note goes to the trash, as if the user had deleted it, and its pins are cleared. The trash empties after 30 days, the next time one of the user's devices opens Blanc; until then the note can be restored. Deleting a note that is already in the trash changes nothing. The API can't delete a note for good.
Parameters
| Name | In | Type | About |
|---|---|---|---|
id | path | string | The note's id, URL-encoded. |
format optional | query | list of html or markdown or text | Which renderings of the body to include, separated by commas, such as markdown,text. html always comes. |
Responses
200 | The note, with deletedAt set. Note. Headers: ETag. |
400 | The request can't be read: a body that isn't a JSON object or isn't sent as application/json, an unknown field or parameter, a parameter sent twice, or a value of the wrong type or out of range. message says which. ApiError. |
401 | No access token, or one that has expired, been revoked, or no longer fits the user's grant. Refresh the token; if the refresh fails with invalid_grant, the user has to connect again. ApiError. Headers: WWW-Authenticate. |
403 | The token is good, but its scopes don't include the one this needs. WWW-Authenticate names it. Connect again asking for it. ApiError. Headers: WWW-Authenticate. |
404 | No such note. A note in another account is a 404 too, never a 403. ApiError. |
409 | locked: the note is locked, and an app can never write to it. No note is locked yet; locking arrives with Blanc's Locked Notes. ApiError. |
429 | Over the rate limit: 600 reads and 120 writes per 5 minutes for each app acting for each user, and ten times that for each app across all its users. Wait the number of seconds in Retry-After. ApiError. Headers: Retry-After, RateLimit, RateLimit-Policy. |
500 | Something went wrong at Blanc. The request may be fine; try again later. ApiError. |
Add to a note
POST /v1/notes/{id}/append Needs notes:write.
Adds blocks after the note's last block that has something in it. With heading, they go at the end of the section under the first level-2 heading that reads the same, ignoring case and extra spaces; a section runs to the next level-1 or level-2 heading. If there is no such heading, Blanc adds it at the end of the note, with the blocks under it.
Append reads the note and writes it back in one step, so two appends never lose each other's blocks. Send an Idempotency-Key so a retry can't add the same blocks twice.
Blanc keeps a version of the note before your app adds to it, labelled Before ‹your app› added to it: at most one per note every ten minutes, and none for a note over 1 MB.
Parameters
| Name | In | Type | About |
|---|---|---|---|
id | path | string | The note's id, URL-encoded. |
format optional | query | list of html or markdown or text | Which renderings of the body to include, separated by commas, such as markdown,text. html always comes. |
Idempotency-Key optional | header | string | A key you make up for this one operation, such as a UUID from your queue, sent again unchanged on every retry. For 24 hours, a request with a key this app already used for this user writes nothing and answers as the first one did, with the note as it is now. Only requests that succeeded count, so a failed request can be retried with the same key. The same key with a different request is a 422: the same request means the same method, path and JSON body, fields in the same order. A key is this app's for this user across every endpoint and note, so use a fresh one for each operation. At most 255 characters. |
Request body
JSON: NoteAppend.
Responses
200 | The note as it is now. A replay of an earlier request with the same Idempotency-Key says Idempotent-Replayed: true and adds nothing. Note. Headers: ETag, Idempotent-Replayed. |
400 | The body can't be read, or there is nothing to add: no markdown or html, or content that is empty once cleaned. ApiError. |
401 | No access token, or one that has expired, been revoked, or no longer fits the user's grant. Refresh the token; if the refresh fails with invalid_grant, the user has to connect again. ApiError. Headers: WWW-Authenticate. |
403 | The token is good, but its scopes don't include the one this needs. WWW-Authenticate names it. Connect again asking for it. ApiError. Headers: WWW-Authenticate. |
404 | No such note. A note in another account is a 404 too, never a 403. ApiError. |
409 | in_trash: the note is in the trash, so restore it first. locked: the note is locked, and an app can never write to it. No note is locked yet; locking arrives with Blanc's Locked Notes. ApiError. |
413 | The body is over the 10 MB limit for note writes. ApiError. |
422 | This Idempotency-Key was used in the last 24 hours for a different request. Nothing was written. ApiError. |
429 | Over the rate limit: 600 reads and 120 writes per 5 minutes for each app acting for each user, and ten times that for each app across all its users. Wait the number of seconds in Retry-After. ApiError. Headers: Retry-After, RateLimit, RateLimit-Policy. |
500 | Something went wrong at Blanc. The request may be fine; try again later. ApiError. |
Take a note out of the trash
POST /v1/notes/{id}/restore Needs notes:write.
Back in the notebooks and tags it had, unpinned. Restoring a note that isn't in the trash changes nothing.
Parameters
| Name | In | Type | About |
|---|---|---|---|
id | path | string | The note's id, URL-encoded. |
format optional | query | list of html or markdown or text | Which renderings of the body to include, separated by commas, such as markdown,text. html always comes. |
Responses
200 | The note, with deletedAt null. Note. Headers: ETag. |
400 | The request can't be read: a body that isn't a JSON object or isn't sent as application/json, an unknown field or parameter, a parameter sent twice, or a value of the wrong type or out of range. message says which. ApiError. |
401 | No access token, or one that has expired, been revoked, or no longer fits the user's grant. Refresh the token; if the refresh fails with invalid_grant, the user has to connect again. ApiError. Headers: WWW-Authenticate. |
403 | The token is good, but its scopes don't include the one this needs. WWW-Authenticate names it. Connect again asking for it. ApiError. Headers: WWW-Authenticate. |
404 | No such note. A note in another account is a 404 too, never a 403. ApiError. |
409 | locked: the note is locked, and an app can never write to it. No note is locked yet; locking arrives with Blanc's Locked Notes. ApiError. |
429 | Over the rate limit: 600 reads and 120 writes per 5 minutes for each app acting for each user, and ten times that for each app across all its users. Wait the number of seconds in Retry-After. ApiError. Headers: Retry-After, RateLimit, RateLimit-Policy. |
500 | Something went wrong at Blanc. The request may be fine; try again later. ApiError. |
Filing
The user's notebooks and tags, read-only, for filing notes and for pickers.
List notebooks
GET /v1/notebooks Needs notes:read.
Every notebook in one page, flat, sorted so that the notebooks inside any one parent are in the sidebar's order; rebuild the tree from parentId. Read-only: apps file notes into the user's notebooks but don't make them.
Responses
200 | All the notebooks. NotebookPage. |
400 | A query parameter was sent; this takes none. ApiError. |
401 | No access token, or one that has expired, been revoked, or no longer fits the user's grant. Refresh the token; if the refresh fails with invalid_grant, the user has to connect again. ApiError. Headers: WWW-Authenticate. |
403 | The token is good, but its scopes don't include the one this needs. WWW-Authenticate names it. Connect again asking for it. ApiError. Headers: WWW-Authenticate. |
429 | Over the rate limit: 600 reads and 120 writes per 5 minutes for each app acting for each user, and ten times that for each app across all its users. Wait the number of seconds in Retry-After. ApiError. Headers: Retry-After, RateLimit, RateLimit-Policy. |
500 | Something went wrong at Blanc. The request may be fine; try again later. ApiError. |
List tags
GET /v1/tags Needs notes:read.
Every tag in one page, in the order the sidebar shows them. Read-only, but naming a new tag when you create or edit a note makes it.
Responses
200 | All the tags. TagPage. |
400 | A query parameter was sent; this takes none. ApiError. |
401 | No access token, or one that has expired, been revoked, or no longer fits the user's grant. Refresh the token; if the refresh fails with invalid_grant, the user has to connect again. ApiError. Headers: WWW-Authenticate. |
403 | The token is good, but its scopes don't include the one this needs. WWW-Authenticate names it. Connect again asking for it. ApiError. Headers: WWW-Authenticate. |
429 | Over the rate limit: 600 reads and 120 writes per 5 minutes for each app acting for each user, and ten times that for each app across all its users. Wait the number of seconds in Retry-After. ApiError. Headers: Retry-After, RateLimit, RateLimit-Policy. |
500 | Something went wrong at Blanc. The request may be fine; try again later. ApiError. |
Meta
This document.
This document
GET /v1/openapi.json No token.
This OpenAPI document, for code generators and API tools. No token needed, and any website may fetch it.
Responses
200 | OpenAPI 3.1. |
Objects
NoteSummary
A note without its body: one item of a list or a changes feed.
| Field | Type | About |
|---|---|---|
id | string | The note's id. Unique within the account. |
title | string | The note's first line, as Blanc shows it in the note list. Untitled when the note has none. |
url | string | The link to show and share, https://open.blancnote.app/n/<id>. It works anywhere a link does: it lands on a page at open.blancnote.app that sends the browser on to the note's blanc://note/<id> link. |
appUrl | string | blanc://note/<id>: the link the url page opens, for native apps that would rather skip the browser. |
notebookIds | list of string | The notebooks the note is filed in, leaving out any in the trash. |
tags | list of string | The note's tags, by name, leaving out any in the trash. |
pinned | boolean | Pinned to the top of every list the note is in. |
archived | boolean | Put away by the user. The API can read archived notes but can't archive or unarchive one. |
deletedAt | timestamp or null | When the note went to the trash, or null. Blanc empties the trash after 30 days. |
locked | boolean | A locked note lists like any other but has no body in any format and an empty preview, and every write to it is a 409 locked. Always false until Blanc's Locked Notes arrives. |
preview | string | The start of the note's text after the title, as the note list shows it. At most 120 characters. |
createdAt | timestamp | |
updatedAt | timestamp | When the note last changed, to the millisecond. Send it back as If-Match. |
NoteContent
The note's body: everything after the title. title travels on its own.
| Field | Type | About |
|---|---|---|
html | string | The body as Blanc stores it, data- attributes and all, so writing it back keeps every live block. The exception is a body whose text has a tab or line break outside a code block: written back, its whitespace collapses. |
markdown may be absent | string | With format=markdown. |
text may be absent | string | With format=text. |
Note
A note and its body: what reads and every successful write return.
| Field | Type | About |
|---|---|---|
id | string | The note's id. Unique within the account. |
title | string | The note's first line, as Blanc shows it in the note list. Untitled when the note has none. |
url | string | The link to show and share, https://open.blancnote.app/n/<id>. It works anywhere a link does: it lands on a page at open.blancnote.app that sends the browser on to the note's blanc://note/<id> link. |
appUrl | string | blanc://note/<id>: the link the url page opens, for native apps that would rather skip the browser. |
notebookIds | list of string | The notebooks the note is filed in, leaving out any in the trash. |
tags | list of string | The note's tags, by name, leaving out any in the trash. |
pinned | boolean | Pinned to the top of every list the note is in. |
archived | boolean | Put away by the user. The API can read archived notes but can't archive or unarchive one. |
deletedAt | timestamp or null | When the note went to the trash, or null. Blanc empties the trash after 30 days. |
locked | boolean | A locked note lists like any other but has no body in any format and an empty preview, and every write to it is a 409 locked. Always false until Blanc's Locked Notes arrives. |
preview | string | The start of the note's text after the title, as the note list shows it. At most 120 characters. |
createdAt | timestamp | |
updatedAt | timestamp | When the note last changed, to the millisecond. Send it back as If-Match. |
content | NoteContent or null | The body, or null when the note is locked. |
{
"id": "note-1792588456123-k3x9",
"title": "Saturday 24 October",
"url": "https://open.blancnote.app/n/note-1792588456123-k3x9",
"appUrl": "blanc://note/note-1792588456123-k3x9",
"notebookIds": [
"nb-journal"
],
"tags": [
"daily"
],
"pinned": false,
"archived": false,
"deletedAt": null,
"locked": false,
"preview": "Morning Walked to the river before work.",
"createdAt": "2026-10-21T13:14:16.123Z",
"updatedAt": "2026-10-23T08:02:51.456Z",
"content": {
"html": "<h2>Morning</h2><p>Walked to the river before work.</p>",
"markdown": "## Morning\n\nWalked to the river before work.\n"
}
}PurgedNote
In a changes feed only: a note that no longer exists anywhere, because the user deleted it for good or the trash emptied. Drop your copy.
| Field | Type | About |
|---|---|---|
id | string | |
purged | true | |
purgedAt | timestamp |
NoteFeedItem
One item of a changes feed. Tell the two kinds apart by purged.
One of: NoteSummary, PurgedNote.
NotePage
A page of a list. While hasMore is true, send nextCursor back as cursor, with the same filters, for the next page.
| Field | Type | About |
|---|---|---|
data | list of NoteSummary | |
hasMore | boolean | |
nextCursor | string or null | Null on the last page. |
NoteFeedPage
A page of a changes feed. While hasMore is true, send nextCursor back as cursor for the next page. Once it's false, keep nextCursor and poll with it later.
| Field | Type | About |
|---|---|---|
data | list of NoteFeedItem | |
hasMore | boolean | |
nextCursor | string | Never null in a feed. |
Notebook
| Field | Type | About |
|---|---|---|
id | string | |
name | string | |
parentId | string or null | The notebook it sits in. Null for a top-level notebook, and for one whose parent no longer exists. |
archived | boolean | Put away by the user. Notes can still be filed in it; a picker would usually hide it. |
noteCount | integer | Notes filed directly in it, as the sidebar counts them: archived notes and the trash aren't counted. |
{
"id": "nb-journal",
"name": "Journal",
"parentId": null,
"archived": false,
"noteCount": 214
}NotebookPage
Every notebook. Never paginated: hasMore is always false.
| Field | Type | About |
|---|---|---|
data | list of Notebook | |
hasMore | boolean | |
nextCursor | string or null |
Tag
Tags are identified by name.
| Field | Type | About |
|---|---|---|
name | string | |
color | string | #rrggbb. |
archived | boolean | Put away by the user, as notebooks can be. |
{
"name": "daily",
"color": "#085C51",
"archived": false
}TagPage
Every tag. Never paginated: hasMore is always false.
| Field | Type | About |
|---|---|---|
data | list of Tag | |
hasMore | boolean | |
nextCursor | string or null |
Me
| Field | Type | About |
|---|---|---|
userId | string | Opaque, and stable for this account as your app sees it. Another app sees a different id for the same account. |
app | object | |
app.clientId | string | |
app.name | string | |
scopes | list of notes:read or notes:write or profile or offline | What this token may do. |
displayName may be absent | string | With the profile scope: the user's nickname if they set one, otherwise their first and last name. Left out when there is neither. |
{
"userId": "LXEWQrcmsEQBYnyp-6wy9chTD7GQPMTbAiWHF5IaSIE",
"app": {
"clientId": "blanc_Xq3vN8rTkL2pW9yZb4cD1e",
"name": "Morning"
},
"scopes": [
"notes:write",
"offline"
]
}NoteInput
A note to create, or the fields of a note to change. Send markdown or html for the body, not both. Any other field is a 400.
| Field | Type | About |
|---|---|---|
title optional | string | One line: runs of spaces and line breaks become single spaces. An empty title makes the note Untitled. At most 1,000 characters. |
markdown optional | string | The body in Markdown (GitHub-flavoured). A single line break joins two lines into one paragraph; leave a blank line between paragraphs. Task lists become Blanc checklists. On a patch, it replaces the whole body. |
html optional | string | The body in HTML, cleaned before it's stored: no scripts, event handlers, iframes, forms or javascript: links. On a patch, it replaces the whole body. |
notebookIds optional | list of string | The notebooks to file the note in. Each must exist. On a patch, replaces the note's notebooks. At most 100 items. Each at most 200 characters. |
tags optional | list of string | Tag names. Each is stored trimmed, in lower case, with spaces as hyphens, and created if it doesn't exist. On a patch, replaces the note's tags. At most 100 items. Each at most 100 characters. |
pinned optional | boolean | Pin the note to the top of every list it's in. |
{
"title": "Saturday 24 October",
"markdown": "## Morning\n\nWalked to the river before work.",
"notebookIds": [
"nb-journal"
],
"tags": [
"daily"
]
}NoteAppend
Blocks to add, as markdown or html (one of them, not both), and where. Any other field is a 400.
| Field | Type | About |
|---|---|---|
markdown optional | string | As for create. |
html optional | string | As for create. |
heading optional | string | Add under the level-2 heading with this text, which is created at the end of the note if it isn't there. At most 500 characters. |
{
"markdown": "- 09:41 Called Amy about Saturday",
"heading": "Log"
}ApiError
The body of every error from a resource endpoint.
| Field | Type | About |
|---|---|---|
error | error code | What went wrong. Switch on this, never on message. |
message | string | For people reading logs. The wording can change; the codes can't. |
{
"error": "in_trash",
"message": "The note is in the trash. Restore it, then write to it."
}PreconditionFailed
A 412: carries the stamp to read again from.
| Field | Type | About |
|---|---|---|
error | "precondition_failed" | |
message | string | For people reading logs. The wording can change; the codes can't. |
currentUpdatedAt | timestamp | The note's updatedAt now. |
AuthorizationCodeTokenRequest
Exchange the code your redirect URI received.
| Field | Type | About |
|---|---|---|
grant_type | "authorization_code" | |
code | string | |
redirect_uri | string | Exactly the redirect_uri the authorization request sent, port included. |
client_id | string | |
code_verifier | string | The PKCE verifier whose SHA-256 you sent as code_challenge: 43 to 128 characters of letters, digits and -._~. |
RefreshTokenRequest
Swap a refresh token for a new access token and a new refresh token.
| Field | Type | About |
|---|---|---|
grant_type | "refresh_token" | |
refresh_token | string | |
client_id | string | |
scope optional | string | Optional: a narrower set of the granted scopes, for an access token that can do less. Leave it out to keep them all. |
RevokeRequest
| Field | Type | About |
|---|---|---|
client_id | string | |
token | string | Your access token or your refresh token. |
token_type_hint optional | string | Accepted and ignored: Blanc tells the two kinds apart itself. |
OAuthToken
RFC 6749 §5.1.
| Field | Type | About |
|---|---|---|
access_token | string | Send as Authorization: Bearer <token>. Treat it as opaque. |
token_type | "Bearer" | |
expires_in | integer | Seconds until the access token expires: 3600. |
refresh_token may be absent | string | Only when the grant includes offline. A new one comes back on every refresh, and the old one stops working. |
scope | string | What this token may do, separated by spaces. |
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.…",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "3c9f0d2a8b7e61f4a05d9c2e.…",
"scope": "notes:read notes:write offline"
}OAuthError
Errors from the token and revoke endpoints (RFC 6749 §5.2).
| Field | Type | About |
|---|---|---|
error | invalid_request or invalid_client or invalid_grant or unauthorized_client or unsupported_grant_type or invalid_scope or server_error | RFC 6749 §5.2. invalid_grant always means the user has to connect again. |
error_description may be absent | string | For people reading logs. |
{
"error": "invalid_grant",
"error_description": "The refresh token has expired."
}Help
Questions, problems and verification requests: api@blancnote.app.