Blanc

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

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:

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:

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.

ScopeOn the consent screenWhat 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.

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.

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"

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.

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.

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:

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.

Trash, versions and locked notes

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.

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.

  1. Start from the beginning to copy everything and then keep up: GET /v1/notes?since=1970-01-01T00:00:00Z returns 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: since is 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.
  2. Read every page. While hasMore is true, ask again with cursor set to nextCursor, and nothing else but limit.
  3. 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." }
errorStatusMeaning
invalid_request400The 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_token401No access token, or an expired or revoked one (RFC 6750).
insufficient_scope403The token is good but lacks the scope this needs (RFC 6750).
not_found404No such note or endpoint. Another account's note is a 404 too, never a 403.
locked409The note is locked. No note is locked until Blanc's Locked Notes arrives.
in_trash409The note is in the trash. Restore it, then write to it.
precondition_failed412If-Match no longer matches: the note changed after you read it.
payload_too_large413The body is over the 10 MB limit.
idempotency_key_reused422The same Idempotency-Key was used for a different request.
rate_limited429Too many requests. Wait for Retry-After.
server_error500Something went wrong at Blanc.

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.

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

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
NameInTypeAbout
response_typequerycodeAlways code.
client_idquerystringYour app's client id.
redirect_uriquerystringOne 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.
scopequerystringOne or more scopes, separated by spaces. Ask for what you need: the consent screen lists each one in plain words.
state optionalquerystringA 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_challengequerystringPKCE (RFC 7636): the base64url SHA-256 of your code verifier, without padding. Required for every app.
code_challenge_methodqueryS256Always S256. plain isn't accepted.
Responses
200The consent screen. An HTML page.
302Back to redirect_uri with error, error_description and state, because the request has a problem your app should hear about. Headers: Location.
400An 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.
500An 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.

FieldTypeAbout
grant_type"authorization_code"
codestring
redirect_uristringExactly the redirect_uri the authorization request sent, port included.
client_idstring
code_verifierstringThe 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.

FieldTypeAbout
grant_type"refresh_token"
refresh_tokenstring
client_idstring
scope optionalstringOptional: a narrower set of the granted scopes, for an access token that can do less. Leave it out to keep them all.
Responses
200Tokens. OAuthToken. Headers: Cache-Control.
400invalid_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.
401invalid_client: client_id is missing or unknown, or the app is disabled. OAuthError.
413invalid_request: a form body over 16 KB. OAuthError.
500server_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
200Done, or the token meant nothing. The body is empty.
400invalid_request: token is missing, or the body can't be read. OAuthError.
401invalid_client: client_id is missing or unknown. OAuthError.
413invalid_request: a form body over 16 KB. OAuthError.
500server_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
200The account and the app, as this token sees them. Me.
401No 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.
429Over 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.
500Something 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
NameInTypeAbout
notebook optionalquerystringOnly notes filed in this notebook, by id. Notes in its sub-notebooks are not included.
tag optionalquerystringOnly 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 optionalquerybooleanOnly pinned notes, or only unpinned ones. This is the pin that tops every list a note is in.
archived optionalquerybooleanOnly archived notes, or only notes that aren't archived. Leave it out for both.
deleted optionalquerybooleantrue lists only the trash. false, like leaving it out, lists everything but the trash.
title optionalquerystringOnly 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 optionalquerystringOnly notes whose title contains this text, ignoring case. Titles only: the API has no full-text search.
since optionalquerytimestampSwitches 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 optionalquerystringThe previous page's nextCursor.
limit optionalqueryintegerItems per page. Default 50. 1 to 100.
Responses
200A page of the list (NotePage) or of the feed (NoteFeedPage). NotePage or NoteFeedPage.
400An unknown parameter, a parameter sent twice, a malformed value or cursor, a filter on a feed, or a list cursor sent with since. ApiError.
401No 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.
403The 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.
429Over 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.
500Something 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
NameInTypeAbout
format optionalquerylist of html or markdown or textWhich renderings of the body to include, separated by commas, such as markdown,text. html always comes.
Idempotency-Key optionalheaderstringA 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
201Created. 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.
400The 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.
401No 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.
403The 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.
404A 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.
413The body is over the 10 MB limit for note writes. ApiError.
422This Idempotency-Key was used in the last 24 hours for a different request. Nothing was written. ApiError.
429Over 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.
500Something 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
NameInTypeAbout
idpathstringThe note's id, URL-encoded.
format optionalquerylist of html or markdown or textWhich renderings of the body to include, separated by commas, such as markdown,text. html always comes.
Responses
200The note. Note. Headers: ETag.
400The 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.
401No 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.
403The 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.
404No such note. A note in another account is a 404 too, never a 403. ApiError.
429Over 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.
500Something 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
NameInTypeAbout
idpathstringThe note's id, URL-encoded.
format optionalquerylist of html or markdown or textWhich renderings of the body to include, separated by commas, such as markdown,text. html always comes.
If-Match optionalheaderstringMake 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
200The note as it is now. Note. Headers: ETag.
400The 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.
401No 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.
403The 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.
404No such note, or a notebook in notebookIds doesn't exist (message names it). ApiError.
409in_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.
412If-Match no longer matches: the note changed after you read it. Nothing was written. Read it again from currentUpdatedAt. PreconditionFailed.
413The body is over the 10 MB limit for note writes. ApiError.
429Over 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.
500Something 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
NameInTypeAbout
idpathstringThe note's id, URL-encoded.
format optionalquerylist of html or markdown or textWhich renderings of the body to include, separated by commas, such as markdown,text. html always comes.
Responses
200The note, with deletedAt set. Note. Headers: ETag.
400The 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.
401No 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.
403The 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.
404No such note. A note in another account is a 404 too, never a 403. ApiError.
409locked: 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.
429Over 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.
500Something 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
NameInTypeAbout
idpathstringThe note's id, URL-encoded.
format optionalquerylist of html or markdown or textWhich renderings of the body to include, separated by commas, such as markdown,text. html always comes.
Idempotency-Key optionalheaderstringA 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
200The 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.
400The body can't be read, or there is nothing to add: no markdown or html, or content that is empty once cleaned. ApiError.
401No 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.
403The 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.
404No such note. A note in another account is a 404 too, never a 403. ApiError.
409in_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.
413The body is over the 10 MB limit for note writes. ApiError.
422This Idempotency-Key was used in the last 24 hours for a different request. Nothing was written. ApiError.
429Over 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.
500Something 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
NameInTypeAbout
idpathstringThe note's id, URL-encoded.
format optionalquerylist of html or markdown or textWhich renderings of the body to include, separated by commas, such as markdown,text. html always comes.
Responses
200The note, with deletedAt null. Note. Headers: ETag.
400The 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.
401No 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.
403The 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.
404No such note. A note in another account is a 404 too, never a 403. ApiError.
409locked: 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.
429Over 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.
500Something 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
200All the notebooks. NotebookPage.
400A query parameter was sent; this takes none. ApiError.
401No 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.
403The 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.
429Over 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.
500Something 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
200All the tags. TagPage.
400A query parameter was sent; this takes none. ApiError.
401No 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.
403The 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.
429Over 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.
500Something 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
200OpenAPI 3.1.

Objects

NoteSummary

A note without its body: one item of a list or a changes feed.

FieldTypeAbout
idstringThe note's id. Unique within the account.
titlestringThe note's first line, as Blanc shows it in the note list. Untitled when the note has none.
urlstringThe 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.
appUrlstringblanc://note/<id>: the link the url page opens, for native apps that would rather skip the browser.
notebookIdslist of stringThe notebooks the note is filed in, leaving out any in the trash.
tagslist of stringThe note's tags, by name, leaving out any in the trash.
pinnedbooleanPinned to the top of every list the note is in.
archivedbooleanPut away by the user. The API can read archived notes but can't archive or unarchive one.
deletedAttimestamp or nullWhen the note went to the trash, or null. Blanc empties the trash after 30 days.
lockedbooleanA 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.
previewstringThe start of the note's text after the title, as the note list shows it. At most 120 characters.
createdAttimestamp
updatedAttimestampWhen 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.

FieldTypeAbout
htmlstringThe 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 absentstringWith format=markdown.
text may be absentstringWith format=text.

Note

A note and its body: what reads and every successful write return.

FieldTypeAbout
idstringThe note's id. Unique within the account.
titlestringThe note's first line, as Blanc shows it in the note list. Untitled when the note has none.
urlstringThe 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.
appUrlstringblanc://note/<id>: the link the url page opens, for native apps that would rather skip the browser.
notebookIdslist of stringThe notebooks the note is filed in, leaving out any in the trash.
tagslist of stringThe note's tags, by name, leaving out any in the trash.
pinnedbooleanPinned to the top of every list the note is in.
archivedbooleanPut away by the user. The API can read archived notes but can't archive or unarchive one.
deletedAttimestamp or nullWhen the note went to the trash, or null. Blanc empties the trash after 30 days.
lockedbooleanA 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.
previewstringThe start of the note's text after the title, as the note list shows it. At most 120 characters.
createdAttimestamp
updatedAttimestampWhen the note last changed, to the millisecond. Send it back as If-Match.
contentNoteContent or nullThe 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.

FieldTypeAbout
idstring
purgedtrue
purgedAttimestamp

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.

FieldTypeAbout
datalist of NoteSummary
hasMoreboolean
nextCursorstring or nullNull 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.

FieldTypeAbout
datalist of NoteFeedItem
hasMoreboolean
nextCursorstringNever null in a feed.

Notebook

FieldTypeAbout
idstring
namestring
parentIdstring or nullThe notebook it sits in. Null for a top-level notebook, and for one whose parent no longer exists.
archivedbooleanPut away by the user. Notes can still be filed in it; a picker would usually hide it.
noteCountintegerNotes 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.

FieldTypeAbout
datalist of Notebook
hasMoreboolean
nextCursorstring or null

Tag

Tags are identified by name.

FieldTypeAbout
namestring
colorstring#rrggbb.
archivedbooleanPut away by the user, as notebooks can be.
{
  "name": "daily",
  "color": "#085C51",
  "archived": false
}

TagPage

Every tag. Never paginated: hasMore is always false.

FieldTypeAbout
datalist of Tag
hasMoreboolean
nextCursorstring or null

Me

FieldTypeAbout
userIdstringOpaque, and stable for this account as your app sees it. Another app sees a different id for the same account.
appobject
app.clientIdstring
app.namestring
scopeslist of notes:read or notes:write or profile or offlineWhat this token may do.
displayName may be absentstringWith 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.

FieldTypeAbout
title optionalstringOne line: runs of spaces and line breaks become single spaces. An empty title makes the note Untitled. At most 1,000 characters.
markdown optionalstringThe 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 optionalstringThe 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 optionallist of stringThe 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 optionallist of stringTag 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 optionalbooleanPin 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.

FieldTypeAbout
markdown optionalstringAs for create.
html optionalstringAs for create.
heading optionalstringAdd 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.

FieldTypeAbout
errorerror codeWhat went wrong. Switch on this, never on message.
messagestringFor 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.

FieldTypeAbout
error"precondition_failed"
messagestringFor people reading logs. The wording can change; the codes can't.
currentUpdatedAttimestampThe note's updatedAt now.

AuthorizationCodeTokenRequest

Exchange the code your redirect URI received.

FieldTypeAbout
grant_type"authorization_code"
codestring
redirect_uristringExactly the redirect_uri the authorization request sent, port included.
client_idstring
code_verifierstringThe 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.

FieldTypeAbout
grant_type"refresh_token"
refresh_tokenstring
client_idstring
scope optionalstringOptional: a narrower set of the granted scopes, for an access token that can do less. Leave it out to keep them all.

RevokeRequest

FieldTypeAbout
client_idstring
tokenstringYour access token or your refresh token.
token_type_hint optionalstringAccepted and ignored: Blanc tells the two kinds apart itself.

OAuthToken

RFC 6749 §5.1.

FieldTypeAbout
access_tokenstringSend as Authorization: Bearer <token>. Treat it as opaque.
token_type"Bearer"
expires_inintegerSeconds until the access token expires: 3600.
refresh_token may be absentstringOnly when the grant includes offline. A new one comes back on every refresh, and the old one stops working.
scopestringWhat 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).

FieldTypeAbout
errorinvalid_request or invalid_client or invalid_grant or unauthorized_client or unsupported_grant_type or invalid_scope or server_errorRFC 6749 §5.2. invalid_grant always means the user has to connect again.
error_description may be absentstringFor people reading logs.
{
  "error": "invalid_grant",
  "error_description": "The refresh token has expired."
}

Help

Questions, problems and verification requests: api@blancnote.app.