{"openapi":"3.1.0","info":{"title":"Blanc API","version":"1.0.0","summary":"Read and write a Blanc user's notes, with their permission.","description":"The Blanc API lets your app read a user's notes and add to them, once the user has connected it on Blanc's consent screen. Apps connect with OAuth 2.0 and PKCE, and the user can disconnect an app at any time.\n\nThis document is the reference. The guide at https://docs.blancnote.app/ covers the connect flow step by step, what happens to the Markdown and HTML you send, polling for changes, rate limits, errors and the deprecation policy.\n\nResponses are JSON, apart from the consent screen and the empty answer to a revoke. Resource fields are camelCase; the token endpoint's are snake_case, because RFC 6749 fixes them. Adding a field or an optional parameter is not a breaking change, so ignore fields you don't know. Breaking changes ship as `/v2`, with `/v1` kept running for 12 months after `/v2` arrives.","contact":{"name":"Blanc developers","url":"https://docs.blancnote.app/","email":"api@blancnote.app"}},"externalDocs":{"description":"The guide: connecting, content rules, polling, limits and errors","url":"https://docs.blancnote.app/"},"servers":[{"url":"https://api.blancnote.app"}],"tags":[{"name":"OAuth","description":"Connecting your app to a user's Blanc account, and disconnecting it."},{"name":"Identity","description":"Who a token acts for."},{"name":"Notes","description":"The notes themselves: list, read, create, edit, add to, trash and restore. The API can't delete a note for good."},{"name":"Filing","description":"The user's notebooks and tags, read-only, for filing notes and for pickers."},{"name":"Meta","description":"This document."}],"security":[{"blancOAuth":[]}],"paths":{"/v1/oauth/authorize":{"get":{"tags":["OAuth"],"operationId":"authorize","summary":"Ask the user to connect your app","description":"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.\n\nOn 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.\n\nWhen 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.","security":[],"parameters":[{"name":"response_type","in":"query","required":true,"description":"Always `code`.","schema":{"type":"string","enum":["code"]}},{"name":"client_id","in":"query","required":true,"description":"Your app's client id.","schema":{"type":"string","pattern":"^[A-Za-z0-9_-]{16,64}$"}},{"name":"redirect_uri","in":"query","required":true,"description":"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.","schema":{"type":"string","format":"uri"}},{"name":"scope","in":"query","required":true,"description":"One or more scopes, separated by spaces. Ask for what you need: the consent screen lists each one in plain words.","schema":{"type":"string","examples":["notes:read notes:write offline"]}},{"name":"state","in":"query","required":false,"description":"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.","schema":{"type":"string","maxLength":1024}},{"name":"code_challenge","in":"query","required":true,"description":"PKCE (RFC 7636): the base64url SHA-256 of your code verifier, without padding. Required for every app.","schema":{"type":"string","pattern":"^[A-Za-z0-9_-]{43}$"}},{"name":"code_challenge_method","in":"query","required":true,"description":"Always `S256`. `plain` isn't accepted.","schema":{"type":"string","enum":["S256"]}}],"responses":{"200":{"description":"The consent screen.","content":{"text/html":{"schema":{"type":"string"}}}},"302":{"description":"Back to `redirect_uri` with `error`, `error_description` and `state`, because the request has a problem your app should hear about.","headers":{"Location":{"description":"Your redirect URI with the error added.","schema":{"type":"string","format":"uri"}}}},"400":{"description":"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.","content":{"text/html":{"schema":{"type":"string"}}}},"500":{"description":"An error page for the user: something went wrong at Blanc.","content":{"text/html":{"schema":{"type":"string"}}}}}}},"/v1/oauth/token":{"post":{"tags":["OAuth"],"operationId":"token","summary":"Get tokens","description":"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.\n\nAn 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.\n\n**`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.","security":[],"requestBody":{"required":true,"content":{"application/x-www-form-urlencoded":{"schema":{"oneOf":[{"$ref":"#/components/schemas/AuthorizationCodeTokenRequest"},{"$ref":"#/components/schemas/RefreshTokenRequest"}]}}}},"responses":{"200":{"description":"Tokens.","headers":{"Cache-Control":{"$ref":"#/components/headers/NoStore"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthToken"}}}},"400":{"description":"`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).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"}}}},"401":{"description":"`invalid_client`: `client_id` is missing or unknown, or the app is disabled.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"}}}},"413":{"description":"`invalid_request`: a form body over 16 KB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"}}}},"500":{"description":"`server_error`: the request may be fine; try again later.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"}}}}}}},"/v1/oauth/revoke":{"post":{"tags":["OAuth"],"operationId":"revoke","summary":"Disconnect","description":"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.","security":[],"requestBody":{"required":true,"content":{"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/RevokeRequest"}}}},"responses":{"200":{"description":"Done, or the token meant nothing. The body is empty."},"400":{"description":"`invalid_request`: `token` is missing, or the body can't be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"}}}},"401":{"description":"`invalid_client`: `client_id` is missing or unknown.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"}}}},"413":{"description":"`invalid_request`: a form body over 16 KB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"}}}},"500":{"description":"`server_error`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthError"}}}}}}},"/v1/me":{"get":{"tags":["Identity"],"operationId":"getMe","summary":"Who the token acts for","description":"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.","security":[{"blancOAuth":[]}],"responses":{"200":{"description":"The account and the app, as this token sees them.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Me"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/v1/notes":{"get":{"tags":["Notes"],"operationId":"listNotes","summary":"List notes, or the changes since a time","description":"Two modes.\n\n**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`.\n\n**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.\n\nCursors are opaque, and a list's cursor and a feed's cursor don't mix.","security":[{"blancOAuth":["notes:read"]}],"parameters":[{"name":"notebook","in":"query","description":"Only notes filed in this notebook, by id. Notes in its sub-notebooks are not included.","schema":{"type":"string"}},{"name":"tag","in":"query","description":"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`.","schema":{"type":"string"}},{"name":"pinned","in":"query","description":"Only pinned notes, or only unpinned ones. This is the pin that tops every list a note is in.","schema":{"type":"boolean"}},{"name":"archived","in":"query","description":"Only archived notes, or only notes that aren't archived. Leave it out for both.","schema":{"type":"boolean"}},{"name":"deleted","in":"query","description":"`true` lists only the trash. `false`, like leaving it out, lists everything but the trash.","schema":{"type":"boolean"}},{"name":"title","in":"query","description":"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.","schema":{"type":"string"}},{"name":"q","in":"query","description":"Only notes whose title contains this text, ignoring case. Titles only: the API has no full-text search.","schema":{"type":"string"}},{"name":"since","in":"query","description":"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`.","schema":{"type":"string","format":"date-time","pattern":"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,6})?(Z|[+-]\\d{2}:\\d{2})$"}},{"name":"cursor","in":"query","description":"The previous page's `nextCursor`.","schema":{"type":"string"}},{"name":"limit","in":"query","description":"Items per page.","schema":{"type":"integer","minimum":1,"maximum":100,"default":50}}],"responses":{"200":{"description":"A page of the list (`NotePage`) or of the feed (`NoteFeedPage`).","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/NotePage"},{"$ref":"#/components/schemas/NoteFeedPage"}]}}}},"400":{"$ref":"#/components/responses/BadRequest","description":"An unknown parameter, a parameter sent twice, a malformed value or cursor, a filter on a feed, or a list cursor sent with `since`."},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}},"post":{"tags":["Notes"],"operationId":"createNote","summary":"Create a note","description":"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*.\n\nTags 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.\n\nSend an `Idempotency-Key` so a retry can't create the note twice.","security":[{"blancOAuth":["notes:write"]}],"parameters":[{"$ref":"#/components/parameters/Format"},{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NoteInput"}}}},"responses":{"201":{"description":"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.","headers":{"Location":{"description":"The note's address in the API: `/v1/notes/<id>`.","schema":{"type":"string"}},"ETag":{"$ref":"#/components/headers/ETag"},"Idempotent-Replayed":{"$ref":"#/components/headers/IdempotentReplayed"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Note"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound","description":"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."},"413":{"$ref":"#/components/responses/PayloadTooLarge"},"422":{"$ref":"#/components/responses/IdempotencyKeyReused"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/v1/notes/{id}":{"parameters":[{"$ref":"#/components/parameters/NoteId"}],"get":{"tags":["Notes"],"operationId":"getNote","summary":"Read a note","description":"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.","security":[{"blancOAuth":["notes:read"]}],"parameters":[{"$ref":"#/components/parameters/Format"}],"responses":{"200":{"description":"The note.","headers":{"ETag":{"$ref":"#/components/headers/ETag"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Note"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}},"patch":{"tags":["Notes"],"operationId":"updateNote","summary":"Edit a note","description":"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.\n\nBefore 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.\n\nTo 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`.\n\nA note in the trash can't be edited: restore it first.","security":[{"blancOAuth":["notes:write"]}],"parameters":[{"$ref":"#/components/parameters/Format"},{"$ref":"#/components/parameters/IfMatch"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NoteInput"}}}},"responses":{"200":{"description":"The note as it is now.","headers":{"ETag":{"$ref":"#/components/headers/ETag"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Note"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound","description":"No such note, or a notebook in `notebookIds` doesn't exist (`message` names it)."},"409":{"$ref":"#/components/responses/Conflict"},"412":{"$ref":"#/components/responses/PreconditionFailed"},"413":{"$ref":"#/components/responses/PayloadTooLarge"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}},"delete":{"tags":["Notes"],"operationId":"trashNote","summary":"Move a note to the trash","description":"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.","security":[{"blancOAuth":["notes:write"]}],"parameters":[{"$ref":"#/components/parameters/Format"}],"responses":{"200":{"description":"The note, with `deletedAt` set.","headers":{"ETag":{"$ref":"#/components/headers/ETag"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Note"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Locked"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/v1/notes/{id}/append":{"parameters":[{"$ref":"#/components/parameters/NoteId"}],"post":{"tags":["Notes"],"operationId":"appendToNote","summary":"Add to a note","description":"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.\n\nAppend 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.\n\nBlanc 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.","security":[{"blancOAuth":["notes:write"]}],"parameters":[{"$ref":"#/components/parameters/Format"},{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NoteAppend"}}}},"responses":{"200":{"description":"The note as it is now. A replay of an earlier request with the same `Idempotency-Key` says `Idempotent-Replayed: true` and adds nothing.","headers":{"ETag":{"$ref":"#/components/headers/ETag"},"Idempotent-Replayed":{"$ref":"#/components/headers/IdempotentReplayed"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Note"}}}},"400":{"$ref":"#/components/responses/BadRequest","description":"The body can't be read, or there is nothing to add: no `markdown` or `html`, or content that is empty once cleaned."},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"413":{"$ref":"#/components/responses/PayloadTooLarge"},"422":{"$ref":"#/components/responses/IdempotencyKeyReused"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/v1/notes/{id}/restore":{"parameters":[{"$ref":"#/components/parameters/NoteId"}],"post":{"tags":["Notes"],"operationId":"restoreNote","summary":"Take a note out of the trash","description":"Back in the notebooks and tags it had, unpinned. Restoring a note that isn't in the trash changes nothing.","security":[{"blancOAuth":["notes:write"]}],"parameters":[{"$ref":"#/components/parameters/Format"}],"responses":{"200":{"description":"The note, with `deletedAt` null.","headers":{"ETag":{"$ref":"#/components/headers/ETag"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Note"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Locked"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/v1/notebooks":{"get":{"tags":["Filing"],"operationId":"listNotebooks","summary":"List notebooks","description":"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.","security":[{"blancOAuth":["notes:read"]}],"responses":{"200":{"description":"All the notebooks.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotebookPage"}}}},"400":{"$ref":"#/components/responses/BadRequest","description":"A query parameter was sent; this takes none."},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/v1/tags":{"get":{"tags":["Filing"],"operationId":"listTags","summary":"List tags","description":"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.","security":[{"blancOAuth":["notes:read"]}],"responses":{"200":{"description":"All the tags.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TagPage"}}}},"400":{"$ref":"#/components/responses/BadRequest","description":"A query parameter was sent; this takes none."},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/v1/openapi.json":{"get":{"tags":["Meta"],"operationId":"getOpenApi","summary":"This document","description":"This OpenAPI document, for code generators and API tools. No token needed, and any website may fetch it.","security":[],"responses":{"200":{"description":"OpenAPI 3.1.","content":{"application/json":{"schema":{"type":"object"}}}}}}}},"components":{"securitySchemes":{"blancOAuth":{"type":"oauth2","description":"OAuth 2.0, authorization code with PKCE (S256), for every app. Send the access token as `Authorization: Bearer <token>`. `notes:write` includes `notes:read`. Revoking, narrowing or disabling access takes effect on the next request, whatever the token's expiry says.","flows":{"authorizationCode":{"authorizationUrl":"https://api.blancnote.app/v1/oauth/authorize","tokenUrl":"https://api.blancnote.app/v1/oauth/token","refreshUrl":"https://api.blancnote.app/v1/oauth/token","x-usePkce":"SHA-256","scopes":{"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."}}}}},"parameters":{"NoteId":{"name":"id","in":"path","required":true,"description":"The note's id, URL-encoded.","schema":{"type":"string","examples":["note-1792588456123-k3x9"]}},"Format":{"name":"format","in":"query","required":false,"description":"Which renderings of the body to include, separated by commas, such as `markdown,text`. `html` always comes.","style":"form","explode":false,"schema":{"type":"array","items":{"type":"string","enum":["html","markdown","text"]}}},"IdempotencyKey":{"name":"Idempotency-Key","in":"header","required":false,"description":"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.","schema":{"type":"string","minLength":1,"maxLength":255,"pattern":"^[\\x21-\\x7E]+$"}},"IfMatch":{"name":"If-Match","in":"header","required":false,"description":"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`.","schema":{"type":"string"}}},"headers":{"ETag":{"description":"The note's `updatedAt`, in quotes. Send it back as `If-Match`.","schema":{"type":"string","examples":["\"2026-10-23T08:02:51.456Z\""]}},"IdempotentReplayed":{"description":"`true` when this answer replays an earlier request with the same `Idempotency-Key`, so nothing was written this time. Absent otherwise.","schema":{"type":"string","enum":["true"]}},"RetryAfter":{"description":"Seconds to wait before trying again.","schema":{"type":"integer","minimum":0}},"RateLimit":{"description":"On every resource response: what's left of each limit the request counts against, in the IETF draft's format, such as `\"reads-per-user\"; r=599; t=300, \"reads-per-app\"; r=5999; t=300` (requests left, seconds until the window resets).","schema":{"type":"string"}},"RateLimitPolicy":{"description":"On every resource response: each limit the request counts against, such as `\"reads-per-user\"; q=600; w=300`, with a partition key.","schema":{"type":"string"}},"WWWAuthenticate":{"description":"`Bearer realm=\"blanc\"`, plus `error=\"invalid_token\"` when a token was sent but isn't good (RFC 6750), or `error=\"insufficient_scope\"` and the `scope` needed.","schema":{"type":"string"}},"NoStore":{"description":"`no-store`: never cache tokens.","schema":{"type":"string","enum":["no-store"]}}},"responses":{"BadRequest":{"description":"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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiError"}],"properties":{"error":{"enum":["invalid_request"]}}}}}},"Unauthorized":{"description":"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.","headers":{"WWW-Authenticate":{"$ref":"#/components/headers/WWWAuthenticate"}},"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiError"}],"properties":{"error":{"enum":["invalid_token"]}}}}}},"Forbidden":{"description":"The token is good, but its scopes don't include the one this needs. `WWW-Authenticate` names it. Connect again asking for it.","headers":{"WWW-Authenticate":{"$ref":"#/components/headers/WWWAuthenticate"}},"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiError"}],"properties":{"error":{"enum":["insufficient_scope"]}}}}}},"NotFound":{"description":"No such note. A note in another account is a `404` too, never a `403`.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiError"}],"properties":{"error":{"enum":["not_found"]}}}}}},"Conflict":{"description":"`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiError"}],"properties":{"error":{"enum":["locked","in_trash"]}}}}}},"Locked":{"description":"`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.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiError"}],"properties":{"error":{"enum":["locked"]}}}}}},"PreconditionFailed":{"description":"`If-Match` no longer matches: the note changed after you read it. Nothing was written. Read it again from `currentUpdatedAt`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PreconditionFailed"}}}},"PayloadTooLarge":{"description":"The body is over the 10 MB limit for note writes.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiError"}],"properties":{"error":{"enum":["payload_too_large"]}}}}}},"IdempotencyKeyReused":{"description":"This `Idempotency-Key` was used in the last 24 hours for a different request. Nothing was written.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiError"}],"properties":{"error":{"enum":["idempotency_key_reused"]}}}}}},"RateLimited":{"description":"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`.","headers":{"Retry-After":{"$ref":"#/components/headers/RetryAfter"},"RateLimit":{"$ref":"#/components/headers/RateLimit"},"RateLimit-Policy":{"$ref":"#/components/headers/RateLimitPolicy"}},"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiError"}],"properties":{"error":{"enum":["rate_limited"]}}}}}},"ServerError":{"description":"Something went wrong at Blanc. The request may be fine; try again later.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/ApiError"}],"properties":{"error":{"enum":["server_error"]}}}}}}},"schemas":{"NoteSummary":{"type":"object","description":"A note without its body: one item of a list or a changes feed.","required":["id","title","url","appUrl","notebookIds","tags","pinned","archived","deletedAt","locked","preview","createdAt","updatedAt"],"properties":{"id":{"type":"string","description":"The note's id. Unique within the account."},"title":{"type":"string","description":"The note's first line, as Blanc shows it in the note list. *Untitled* when the note has none."},"url":{"type":"string","format":"uri","pattern":"^https://open\\.blancnote\\.app/n/","description":"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":{"type":"string","pattern":"^blanc://note/","description":"`blanc://note/<id>`: the link the `url` page opens, for native apps that would rather skip the browser."},"notebookIds":{"type":"array","items":{"type":"string"},"description":"The notebooks the note is filed in, leaving out any in the trash."},"tags":{"type":"array","items":{"type":"string"},"description":"The note's tags, by name, leaving out any in the trash."},"pinned":{"type":"boolean","description":"Pinned to the top of every list the note is in."},"archived":{"type":"boolean","description":"Put away by the user. The API can read archived notes but can't archive or unarchive one."},"deletedAt":{"type":["string","null"],"format":"date-time","description":"When the note went to the trash, or null. Blanc empties the trash after 30 days."},"locked":{"type":"boolean","description":"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":{"type":"string","maxLength":120,"description":"The start of the note's text after the title, as the note list shows it."},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time","description":"When the note last changed, to the millisecond. Send it back as `If-Match`."}}},"NoteContent":{"type":"object","description":"The note's body: everything after the title. `title` travels on its own.","required":["html"],"properties":{"html":{"type":"string","description":"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":{"type":"string","description":"With `format=markdown`."},"text":{"type":"string","description":"With `format=text`."}}},"Note":{"description":"A note and its body: what reads and every successful write return.","allOf":[{"$ref":"#/components/schemas/NoteSummary"}],"required":["content"],"properties":{"content":{"description":"The body, or null when the note is locked.","anyOf":[{"$ref":"#/components/schemas/NoteContent"},{"type":"null"}]}},"examples":[{"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":{"type":"object","description":"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.","required":["id","purged","purgedAt"],"properties":{"id":{"type":"string"},"purged":{"const":true},"purgedAt":{"type":"string","format":"date-time"}}},"NoteFeedItem":{"description":"One item of a changes feed. Tell the two kinds apart by `purged`.","oneOf":[{"$ref":"#/components/schemas/NoteSummary"},{"$ref":"#/components/schemas/PurgedNote"}]},"NotePage":{"type":"object","description":"A page of a list. While `hasMore` is true, send `nextCursor` back as `cursor`, with the same filters, for the next page.","required":["data","hasMore","nextCursor"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/NoteSummary"}},"hasMore":{"type":"boolean"},"nextCursor":{"type":["string","null"],"description":"Null on the last page."}}},"NoteFeedPage":{"type":"object","description":"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.","required":["data","hasMore","nextCursor"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/NoteFeedItem"}},"hasMore":{"type":"boolean"},"nextCursor":{"type":"string","description":"Never null in a feed."}}},"Notebook":{"type":"object","required":["id","name","parentId","archived","noteCount"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"parentId":{"type":["string","null"],"description":"The notebook it sits in. Null for a top-level notebook, and for one whose parent no longer exists."},"archived":{"type":"boolean","description":"Put away by the user. Notes can still be filed in it; a picker would usually hide it."},"noteCount":{"type":"integer","minimum":0,"description":"Notes filed directly in it, as the sidebar counts them: archived notes and the trash aren't counted."}},"examples":[{"id":"nb-journal","name":"Journal","parentId":null,"archived":false,"noteCount":214}]},"NotebookPage":{"type":"object","description":"Every notebook. Never paginated: `hasMore` is always false.","required":["data","hasMore","nextCursor"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Notebook"}},"hasMore":{"type":"boolean"},"nextCursor":{"type":["string","null"]}}},"Tag":{"type":"object","description":"Tags are identified by name.","required":["name","color","archived"],"properties":{"name":{"type":"string"},"color":{"type":"string","description":"`#rrggbb`."},"archived":{"type":"boolean","description":"Put away by the user, as notebooks can be."}},"examples":[{"name":"daily","color":"#085C51","archived":false}]},"TagPage":{"type":"object","description":"Every tag. Never paginated: `hasMore` is always false.","required":["data","hasMore","nextCursor"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Tag"}},"hasMore":{"type":"boolean"},"nextCursor":{"type":["string","null"]}}},"Scope":{"type":"string","enum":["notes:read","notes:write","profile","offline"]},"Me":{"type":"object","required":["userId","app","scopes"],"properties":{"userId":{"type":"string","description":"Opaque, and stable for this account as your app sees it. Another app sees a different id for the same account."},"app":{"type":"object","required":["clientId","name"],"properties":{"clientId":{"type":"string"},"name":{"type":"string"}}},"scopes":{"type":"array","items":{"$ref":"#/components/schemas/Scope"},"description":"What this token may do."},"displayName":{"type":"string","description":"With the `profile` scope: the user's nickname if they set one, otherwise their first and last name. Left out when there is neither."}},"examples":[{"userId":"LXEWQrcmsEQBYnyp-6wy9chTD7GQPMTbAiWHF5IaSIE","app":{"clientId":"blanc_Xq3vN8rTkL2pW9yZb4cD1e","name":"Morning"},"scopes":["notes:write","offline"]}]},"NoteInput":{"type":"object","description":"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`.","additionalProperties":false,"not":{"required":["markdown","html"]},"properties":{"title":{"type":"string","maxLength":1000,"description":"One line: runs of spaces and line breaks become single spaces. An empty title makes the note *Untitled*."},"markdown":{"type":"string","description":"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":{"type":"string","description":"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":{"type":"array","items":{"type":"string","minLength":1,"maxLength":200},"maxItems":100,"description":"The notebooks to file the note in. Each must exist. On a patch, replaces the note's notebooks."},"tags":{"type":"array","items":{"type":"string","minLength":1,"maxLength":100},"maxItems":100,"description":"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."},"pinned":{"type":"boolean","description":"Pin the note to the top of every list it's in."}},"examples":[{"title":"Saturday 24 October","markdown":"## Morning\n\nWalked to the river before work.","notebookIds":["nb-journal"],"tags":["daily"]}]},"NoteAppend":{"type":"object","description":"Blocks to add, as `markdown` or `html` (one of them, not both), and where. Any other field is a `400`.","additionalProperties":false,"anyOf":[{"required":["markdown"]},{"required":["html"]}],"not":{"required":["markdown","html"]},"properties":{"markdown":{"type":"string","description":"As for create."},"html":{"type":"string","description":"As for create."},"heading":{"type":"string","minLength":1,"maxLength":500,"description":"Add under the level-2 heading with this text, which is created at the end of the note if it isn't there."}},"examples":[{"markdown":"- 09:41 Called Amy about Saturday","heading":"Log"}]},"ErrorCode":{"type":"string","description":"What went wrong. Switch on this, never on `message`.","enum":["invalid_request","invalid_token","insufficient_scope","not_found","locked","in_trash","precondition_failed","payload_too_large","idempotency_key_reused","rate_limited","server_error"],"x-enumDescriptions":{"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."}},"ApiError":{"type":"object","description":"The body of every error from a resource endpoint.","required":["error","message"],"properties":{"error":{"$ref":"#/components/schemas/ErrorCode"},"message":{"type":"string","description":"For people reading logs. The wording can change; the codes can't."}},"examples":[{"error":"in_trash","message":"The note is in the trash. Restore it, then write to it."}]},"PreconditionFailed":{"description":"A `412`: carries the stamp to read again from.","allOf":[{"$ref":"#/components/schemas/ApiError"}],"required":["currentUpdatedAt"],"properties":{"error":{"const":"precondition_failed"},"currentUpdatedAt":{"type":"string","format":"date-time","description":"The note's `updatedAt` now."}}},"AuthorizationCodeTokenRequest":{"type":"object","description":"Exchange the code your redirect URI received.","required":["grant_type","code","redirect_uri","client_id","code_verifier"],"properties":{"grant_type":{"const":"authorization_code"},"code":{"type":"string"},"redirect_uri":{"type":"string","description":"Exactly the `redirect_uri` the authorization request sent, port included."},"client_id":{"type":"string"},"code_verifier":{"type":"string","pattern":"^[A-Za-z0-9._~-]{43,128}$","description":"The PKCE verifier whose SHA-256 you sent as `code_challenge`: 43 to 128 characters of letters, digits and `-._~`."}}},"RefreshTokenRequest":{"type":"object","description":"Swap a refresh token for a new access token and a new refresh token.","required":["grant_type","refresh_token","client_id"],"properties":{"grant_type":{"const":"refresh_token"},"refresh_token":{"type":"string"},"client_id":{"type":"string"},"scope":{"type":"string","description":"Optional: a narrower set of the granted scopes, for an access token that can do less. Leave it out to keep them all."}}},"RevokeRequest":{"type":"object","required":["client_id","token"],"properties":{"client_id":{"type":"string"},"token":{"type":"string","description":"Your access token or your refresh token."},"token_type_hint":{"type":"string","description":"Accepted and ignored: Blanc tells the two kinds apart itself."}}},"OAuthToken":{"type":"object","description":"RFC 6749 §5.1.","required":["access_token","token_type","expires_in","scope"],"properties":{"access_token":{"type":"string","description":"Send as `Authorization: Bearer <token>`. Treat it as opaque."},"token_type":{"const":"Bearer"},"expires_in":{"type":"integer","description":"Seconds until the access token expires: 3600."},"refresh_token":{"type":"string","description":"Only when the grant includes `offline`. A new one comes back on every refresh, and the old one stops working."},"scope":{"type":"string","description":"What this token may do, separated by spaces."}},"examples":[{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.…","token_type":"Bearer","expires_in":3600,"refresh_token":"3c9f0d2a8b7e61f4a05d9c2e.…","scope":"notes:read notes:write offline"}]},"OAuthErrorCode":{"type":"string","description":"RFC 6749 §5.2. `invalid_grant` always means the user has to connect again.","enum":["invalid_request","invalid_client","invalid_grant","unauthorized_client","unsupported_grant_type","invalid_scope","server_error"]},"OAuthError":{"type":"object","description":"Errors from the token and revoke endpoints (RFC 6749 §5.2).","required":["error"],"properties":{"error":{"$ref":"#/components/schemas/OAuthErrorCode"},"error_description":{"type":"string","description":"For people reading logs."}},"examples":[{"error":"invalid_grant","error_description":"The refresh token has expired."}]}}}}