SYSTEM NOTICE

Auto translation by AI. Be sure, accuracy, nuances and authorial intent may not be fully reflected.
見出し画像

A Thorough Investigation of the Unofficial note API | 2026 Complete List of Endpoints

As of May 2026, this is likely the most comprehensive article explaining the list of unofficial note API endpoints (probably). From magazine CRUD, memberships (/api/v2/circle/...), and bulletin boards (/api/v2/boards/...) to handling note_draft when editing paid articles, and the mandatory reCAPTCHA v3 for the authentication API (a breaking change in late May 2026), everything has been identified based on my own hands-on verification.

The first edition was published in May 2026, but I have been updating it as needed to keep track of specification changes on note's side, and most recently in August 2026 I added findings regarding the GraphQL API. The history of updates is summarized in the update log at the end of the article.

The starting point was the preceding articles by ego_station, fuji1080, nori_nw, and others. I have verified and updated them to match the behavior as of 2026, and expanded the scope to include areas that have grown over the past two years (magazine CRUD, memberships, bulletin boards, etc.). If you find something that is even more comprehensive, please let me know.

2026 note Unofficial API Endpoint Map
2026 note Unofficial API Endpoint Map

Introduction

Scope

  • A list of endpoints for the unofficial note.com API within the scope of my observations

  • Request methods, paths, and major payload fields

  • Differences from existing explanatory articles (new discoveries, corrections)

Intended Audience

  • Engineers who want to understand how note works

  • People who want to write their own utility scripts in the absence of an official API

  • note users who want to know the reality of the unofficial API

Disclaimer

Since the unofficial note API is not officially provided, it is in a gray zone according to the terms of service and robots.txt. Specifications change frequently, and you must treat it with the assumption that it could stop working at any time.

This article is merely a "record of behavioral observation" and does not recommend mass access or business use that would cause trouble for the operators. Please limit use to low-frequency personal use.

Please read on with the shared understanding that there is always a possibility of being blocked by invisible reCAPTCHA or behavioral changes (in fact, login was blocked by reCAPTCHA v3 starting in late May 2026. For details, see the update in the "Authentication API List" section).

Preceding Articles Referenced

I referred to the following articles as the starting point for my investigation.

I felt that ego_station's 2024 list was widely referenced as a standard reference for working with the note API.

The same content is also reposted on their own blog.

fuji1080's July 2025 version. It was helpful as a recent snapshot.

An article by nori_nw on the discovery of the pay_body field. It explains how the body of paid articles is structured.

hagure_melon's 2020 version. It is useful for tracing historical context.

An observation article by applikengo_25626.

This time, based on these, I have added behaviors from a newer period (2026) and ranges that were not included in those articles at the time.

Observation Method

For the endpoint investigation, I used a combination of the browser's DevTools network panel and a method of injecting JavaScript into the page to hook fetch / XHR.

Chrome DevTools Network Panel

This is the most basic method. By narrowing the filter to something like note.com/api/, you can see all the requests that fly in response to page transitions and operations.

However, the note Web UI uses Next.js SSR, Service Workers, and server actions in some places, so there were some requests that did not appear in the network panel. In particular, the PUT /api/v1/text_notes/{id} request when clicking the article publish button did not appear in the DevTools request list in my environment.

fetch / XHR monkey-patch

Observation flow for storing Chrome DevTools Network panel and fetch / XHR monkey-patch in localStorage
Observation flow for storing Chrome DevTools Network panel and fetch / XHR monkey-patch in localStorage

For cases missed by the network panel, it was effective to inject the following JS into the page to record all requests.

const orig = window.fetch.bind(window);
window.fetch = async function(input, init) {
  const url = typeof input === "string" ? input : input.url;
  const method = (init && init.method) || "GET";
  const body = init && init.body ? String(init.body).slice(0, 2000) : null;
  console.log({ url, method, body });
  return orig(input, init);
};

const oxopen = XMLHttpRequest.prototype.open;
const oxsend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url) {
  this.__url = url; this.__method = method;
  return oxopen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function(body) {
  console.log({ url: this.__url, method: this.__method, body: body && String(body).slice(0, 2000) });
  return oxsend.apply(this, arguments);
};

If you set this up as a Tampermonkey userscript and keep it resident on note.com and editor.note.com, you can record all requests to localStorage even across reloads and SPA transitions. It is very convenient because you can also see the POST/PUT/DELETE payloads as they are.

Extended version for observing write APIs (capturing headers as well)

The basic version above can pick up the URL / method / body, but it does not capture request headers. With this, you would overlook requirements of the class where 'write operations return 500 if a specific custom header is not attached' (in fact, I didn't notice the X-Note-Client-Code header requirement for the v3 comment API mentioned later until I observed the headers).

When seriously investigating write-type APIs, you can significantly reduce oversights by preparing an extended version like the one below and installing a hook that **narrows down to POST / PUT / PATCH / DELETE and stores them in localStorage along with their headers**.

const STORE = "__note_api_write_tap";
const MAX_ENTRIES = 100;
const URL_MATCH = /\/api\/v\d+\//;
const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);

function headersToObj(h) {
  if (!h) return {};
  if (h instanceof Headers) {
    const o = {}; h.forEach((v, k) => { o[k] = v; }); return o;
  }
  if (Array.isArray(h)) return Object.fromEntries(h);
  return { ...h };
}
function bodyToStr(b) {
  if (b == null) return "";
  if (typeof b === "string") return b;
  if (b instanceof FormData) {
    const parts = [];
    b.forEach((v, k) => parts.push([k, typeof v === "string" ? v : `[${v.constructor?.name}]`]));
    return "[FormData] " + JSON.stringify(parts);
  }
  if (b instanceof Blob) return `[Blob ${b.size}]`;
  if (b instanceof URLSearchParams) return b.toString();
  try { return JSON.stringify(b); } catch { return String(b); }
}
function record(method, url, headers, body) {
  const m = String(method || "GET").toUpperCase();
  if (!WRITE_METHODS.has(m) || !URL_MATCH.test(url)) return;
  const list = JSON.parse(localStorage.getItem(STORE) || "[]");
  list.push({ ts: new Date().toISOString(), method: m, url, headers, body: bodyToStr(body) });
  while (list.length > MAX_ENTRIES) list.shift();
  localStorage.setItem(STORE, JSON.stringify(list));
}

const origFetch = window.fetch.bind(window);
window.fetch = function (input, init) {
  const url = typeof input === "string" ? input : (input && input.url) || "";
  const method = (init && init.method) || (input && input.method) || "GET";
  const headers = { ...headersToObj(input && input.headers), ...headersToObj(init && init.headers) };
  record(method, url, headers, init && init.body);
  return origFetch(input, init);
};

const oOpen = XMLHttpRequest.prototype.open;
const oSetHeader = XMLHttpRequest.prototype.setRequestHeader;
const oSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
  this.__m = method; this.__u = url; this.__h = {};
  return oOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.setRequestHeader = function (k, v) {
  (this.__h = this.__h || {})[k] = v;
  return oSetHeader.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function (body) {
  record(this.__m, this.__u, this.__h || {}, body);
  return oSend.apply(this, arguments);
};

If you type JSON.parse(localStorage.getItem('__note_api_write_tap') || '[]') in the DevTools console, you can see a list of all write requests recorded so far. Since headers like X-CSRF-Token, X-Note-Client-Code, Authorization, Origin, and Referer are visible as-is, you won't miss which headers are required when reproducing them in a daemon or CLI script.

Because it is limited to write-related operations and also filters URLs with /api/v\d+/, telemetry-related requests like /api/v3/trackings/fp are automatically excluded, significantly reducing noise. It is convenient to perform operations like form interactions, article publishing, and comment posting on a real device, then export the contents of localStorage to use as a reference for daemon implementation.

Observation Tips

  • Always set a hook right before clicking buttons that cannot be undone, such as "Post" or "Update". Since the monkey-patch disappears due to redirects after clicking, writing to localStorage to persist the data will reduce data loss.

  • Drafts currently being edited can also be retrieved via GET with the ?draft=true query, so don't overlook URL query parameters when observing the edit screen.

  • For workflows with many types of requests, it is easier to view them if you filter out unnecessary tracking-related items (such as /api/v3/trackings/fp).

List of Authentication APIs

Authentication for note uses a session cookie method with email + password.

POST /api/v1/sessions/sign_in

Send { "login": "email address", "password": "..." } in the request body as JSON. Upon success, the _note_session_v5 cookie is set, and subsequent API calls made with the same cookie will be treated as authenticated.

Currently, note does not have 2FA, and CAPTCHA does not appear during normal login. While being able to log in programmatically is convenient, it is also a point to be aware of. (Struck through due to the 2026-05-22 update: See the update below)

  • Calling without a reCAPTCHA token → Returns HTTP 2xx, but the Set-Cookie: _note_session_v5=... header is not attached. Since it is not an error response, it appears to the client as a mysterious phenomenon where "it succeeded but the session could not be retrieved".

  • Calling with a reCAPTCHA token (= normal login via browser) → A cookie is issued as usual, and subsequent APIs can be called.

[2026-05-22 Update] reCAPTCHA v3 is now mandatory for this endpoint
From late May 2026 (based on my observations, starting 2026-05-21), the g_recaptcha_response field (reCAPTCHA v3 token) has become mandatory in the request payload for POST /api/v1/sessions/sign_in. The endpoint itself is the same, and neither the method nor the path has changed. What has changed is the behavior when the reCAPTCHA token is missing from the payload, which results in the "looks like a success but no cookie is issued" behavior described below. The old payload had 3 fields: {"login": "...", "password": "...", "redirect_path": ""}, but now g_recaptcha_response: "0cAFcWeA..." has been added. If you use "Copy as cURL" in the Chrome DevTools Network panel, you can get a curl command with the token included, which is the fastest way to observe the current state. As a result,

the path of logging in and obtaining a session using only email + password from your own script has effectively been blocked
. Since a reCAPTCHA v3 token cannot be generated without the Google SDK + a valid site key + a browser environment, it is not realistic to forge and attach it from the server side or CLI. The method of "calling with email + password to collect _note_session_v5" that was introduced in this article cannot be used for new sessions after May 2026. A realistic workaround is to decrypt the _note_session_v5 cookie already logged into your daily-use Chrome, etc., via the OS key storage, and inject it into your own tool
. If you combine the Chrome cookie store (SQLite) and the OS key storage (libsecret for Linux, Keychain for macOS, DPAPI for Windows), you can read it from your own process. In Python, this can be done in one go with a library like browser_cookie3, and equivalent implementations exist for other languages. Automating note on a real device was more realistic by shifting in this direction rather than trying to bypass CAPTCHA. Keywords for the encryption architecture: branch between v10 / v11 / v20 via encrypted_value[:3] / Linux & macOS use PBKDF2-derived key + AES-CBC / Windows uses DPAPI-wrapped key + AES-GCM / v20 (Chrome 127+ Windows) uses app-bound encryption, which is generally inaccessible in user-mode (there may be a feature flag in Chrome to disable this, but it has not been investigated) / For details, refer to Chromium's components/os_crypt/.



GET /api/v2/current_user

Returns basic information for the logged-in user. There is also a variant called /api/v2/current_user/email, which returns detailed information including the email address.

Cookie Persistence

The _note_session_v5 cookie seems to have an expiration period of several months, and I was able to continue using it without any problems even without periodic login operations. If you persist the cookie, you do not need to call sign-in every time.

List of Article-related APIs (GET series)

The note API is internally divided into resource systems for "articles," "magazines," "memberships," "users," "bulletin boards," "hashtags," and "authentication," each of which hangs under a different path.

Major resources of the note internal API (Articles / Magazines / Memberships / Users / Bulletin Boards / Hashtags / Authentication)
Major resources of the note internal API (Articles / Magazines / Memberships / Users / Bulletin Boards / Hashtags / Authentication)

Most of the note article-related features are consolidated into the v3 API, but some v2 and v1 endpoints are mixed in.

GET /api/v3/notes

Retrieves the note timeline (browsing public articles). Query parameters such as kind and status can be passed through as-is.

GET /api/v3/notes/{key}

Retrieves details of a note article. {key} is a string starting with 'n' (e.g., n85fcb635c0a9). The response contains fields such as name, body, description, price, separator, is_my_note, is_purchased, and can_* flags.

**If you add the ?draft=true&draft_reedit=false query**, it returns details from the editor's perspective. This is surprisingly important; for paid articles, data.note_draft.body contains the full HTML with the free and paid sections combined. At the same time, data.note_draft.separator contains the block id (UUID) that serves as the boundary; the structure is such that paragraphs up to this UUID are free, and everything after is paid.

GET /api/v3/searches

Cross-search. Query parameters are in the form of context=note|user|magazine|hashtag|circle|noteForSale, q=keyword, size=10, start=0. By switching the context, you can perform cross-searches across notes, users, magazines, hashtags, memberships, and paid notes.

You can switch the sort order using the sort parameter (corresponding to the 'Popular', 'Trending', and 'New' tabs in the UI).

  • sort=popular: Popularity order (default)

  • sort=hot: Trending (recent growth rate)

  • sort=new: Newest (descending order by publish date)

size is the number of items per page (20 seems to be the upper limit on the actual device), and start is the paging offset. I have confirmed that popular sorting does not return errors even when paging up to about 1,000 items, but anything beyond that has not been verified.

GET /api/v2/creators/{urlname}

Retrieves information for a creator page. {urlname} is the note.com/<urlname> part of the URL.

GET /api/v2/creators/{urlname}/contents

List of a creator's articles. Add the query kind=note&page=1. For yourself, the literal 'info' can be used in the urlname position (/api/v2/creators/info/contents?kind=note&page=1).

⚠️ This list does not include articles set to 'Hidden from creator page' (exclude_from_creator_top: true).
Observation on actual device (2026-08, an account with many articles): The profile for this account returns a noteCount of 99, but when traversing this list until isLastPage: true, it was cut off at 56 items. I initially suspected a pagination bug, but the 43 missing items simply all have this setting enabled; they still exist normally with status: published if read individually via GET /api/v3/notes/{key}. This is a specification, not a bug. If you want to retrieve all items without omissions, it is reliable to use the monthly archive via GraphQL, which will be described later.


GET /api/v2/creators/{urlname}/archives

Returns a summary of the number of posts per year. It is not affected by exclude_from_creator_top or pinning, and
is the same data used by the top page of the monthly archive ( note.com/{urlname}/archives ).

{
  "data": [
    {
      "year": "2026",
      "totalNum": 94,
      "details": [
        {"summaryDate": "2026-08", "month": "08", "num": 1},
        {"summaryDate": "2026-07", "month": "07", "num": 15}
      ]
    },
    {
      "year": "2025",
      "totalNum": 5,
      "details": [{"summaryDate": "2025-12", "month": "12", "num": 5}]
    }
  ]
}

The sum of details[].num has been confirmed to match the noteCount from GET /api/v2/creators/{urlname} (or GET /api/v2/current_user, etc.).
It is a lightweight endpoint that only returns a list of years and months where posts exist;
it does not return the contents of each month (which articles were posted and how many). To retrieve the contents,
use the GraphQL archive query described later.

While this endpoint is modest on its own, by first retrieving the list of years and months with posts using this REST endpoint, and then querying only those specific periods using the GraphQL archive query, you can
collect all posts without omission or redundancy.
It is a key endpoint for retrieving all articles.

GET /api/v2/note_list/contents

A list of your own notes (the one used by the web UI dashboard). Includes queries such as page=1.

note.com's GraphQL API ( graphql.note.com ) *Added 2026-08

The note.com web frontend is migrating the retrieval of article lists from REST to GraphQL. The endpoint is a single POST https://graphql.note.com/graphql, and you simply send a standard Apollo Client payload containing operationName, query, and variables.

I have confirmed two representative queries.

  • CreatorAllNotesPageQuery ( creatorNotesConnectionByUrlname(urlname, first, after, isPinnedOnCreatorHomeExcluded: true) ): The same data as the new arrivals list on the creator page. Uses Relay-style cursor-based pagination with first/after. Like the REST /contents endpoint, it does not include articles where exclude_from_creator_top: true.

  • CreatorArchivesPageQuery ( noteArchivesConnectionByUrlname(urlname, year, month, first, after) ): The query used by the monthly archive page ( note.com/{urlname}/archives/{year}/{month} ). This ignores exclude_from_creator_top and returns all articles for that year and month. To retrieve all items without gaps, the standard approach is to call this query for each target year and month (you can confirm which years and months have posts using the REST summary API mentioned later).

Both share a fragment called NoteListItem, and in practice, it is sufficient to pick up common.publishedAt, common.likeCount, common.commentCount, openContents.title, pricing.isFree, and pricing.onetimePurchaseLowestPrice. You can safely ignore GraphQL-specific fields like __typename and the Base64-encoded global id.

While CreatorArchivesPageQuery returns 'what was posted in that year and month,' it does not tell you 'which years and months have posts in the first place.' You can first obtain which years and months have posts using the aforementioned REST summary API ( GET /api/v2/creators/{urlname}/archives ). Please refer to that heading for the procedure to retrieve all items by combining these two.

The combined use of REST and GraphQL does not seem limited to article lists. note.com/contests (list of themes/contests) does not have a dedicated API; the page is rendered via Next.js RSC (React Server Components) streaming, which is a third delivery path that is neither REST nor GraphQL. While the migration to GraphQL for article lists appears to have settled down, implementations vary by page, so it is safer to assume that the scope of this migration may continue to expand in the future. At the very least, the GraphQL side is the source of truth for article lists and monthly archives, so it is safe to assume that REST endpoints like /api/v2/creators/{urlname}/contents will be deprecated or phased out in the future.

GET /api/v2/hashtags/{tag}

Retrieves information about a hashtag. v1 ( GET /api/v1/hashtags/{tag} ) also exists in parallel, and since the response format is different, you should switch between them as needed.

The v2 response includes id, name, and count, as well as a **relatedHashtags field (an array of {name, count} for related tags)**. Retrieving related tags is only possible in v2, as v1 lacks this field. This is useful when building tag navigation UIs.

GET /api/v3/hashtags/{tag}/notes

Retrieves a list of articles associated with a specific hashtag. Query parameters:

  • order=popular|new|hot : Sort order (Popular / New / Trending). Entering other values will result in a 400 invalid order type error.

  • page=N : Paging (50 items per page)

  • paid_only=true|false : Whether to filter by paid articles only

The response is in the format { notes[], count, next_page, is_last_page }, and each note has the same schema as /api/v3/notes (key, name, body, eyecatch_url, user, like_count, publish_at, etc.). By combining this with relatedHashtags from GET /api/v2/hashtags/{tag}, you can complete the flow of 'a tag → related tags → article list' using only the API.

GET /api/v2/categories

List of categories. Top categories such as Featured, Manga, Columns, and Essays are listed in data.categories. The key value for each category (e.g., music, it, gadget) is also used when specifying categories during the creation of a paid magazine.

GET /api/v1/categories/{name}

List of articles by category. name is the key value mentioned above. It accepts queries such as note_intro_only=true&sort=new&page=1.

GET /api/v1/magazines/{key}

Retrieves magazine information. {key} is a string starting with 'm'.

GET /api/v3/notice_counts

Retrieves the number of notifications (number of unread DMs, unread notifications, and unread information messages).

GET /api/v1/stats/pv

Retrieves PV statistics for your articles. It accepts queries such as filter=all&page=1&sort=pv.

List of APIs for creating, publishing, editing, and deleting articles

note articles (text_note) are internally state machines that hold a status.

Overview of status transitions

When creating a new one, it starts with status: "" (a reserved state close to unsaved), and when you save the content, it moves to "draft", and when you publish it, it moves to "published". From "published", it can be moved to "deleted" via a soft delete.

POST /api/v1/text_notes

Creates an empty draft reservation. A POST with an empty body is fine. The response returns an id (numeric) and a key (n... format), and it enters the status: "" state. At this point, it is not displayed on the UI.

POST /api/v1/text_notes/draft_save?id={numeric_id}

This is for saving a draft. Send { "name": "...", "body": "<HTML>" } in JSON. Calling this promotes the status to "draft" and makes it appear on your dashboard. Since it uses a differential overwrite method, you must send the entire body every time you change the content.

**Publish-related metafields such as hashtags, magazine_ids, and circle_permissions are silently ignored by this endpoint** (it returns 200 / result: true, but they are not persisted). These settings cannot be reflected unless done via publish (PUT /api/v1/text_notes/{id}), so this cannot be used for building content incrementally at the draft stage.

PUT /api/v1/text_notes/{numeric_id}

This is for publish (draft → published). Send the full payload (all fields including status="published"). The main fields are as follows.

Payload structure for PUT /api/v1/text_notes/{id} (relationship between status / name / free_body / pay_body / separator / price / note_draft)
Payload structure for PUT /api/v1/text_notes/{id} (relationship between status / name / free_body / pay_body / separator / price / note_draft)
  • status : Required. "published"

  • name : Title

  • free_body : Free section HTML. Each paragraph has a block id like <p name="UUID" id="UUID">...</p>

  • pay_body : Paid section HTML (for paid articles only)

  • separator : The id of the last paragraph of free_body (= boundary of the free section)

  • price : Price (0 for free, 100 to 50000)

  • slug , body_length , hashtags , image_keys , magazine_ids , magazine_keys

  • disable_comment , limited , is_refund , index

  • exclude_from_creator_top , exclude_ai_learning_reward

  • send_notifications_flag , author_ids

  • circle_permissions : Specification for membership-only visibility (details in the subsection below). Use an empty array for "no restrictions"

  • discount_campaigns , lead_form , line_add_friend , line_add_friend_access_token , pro_coupon_keys

This endpoint is not exclusively for publishing; it is also used for editing already published articles. The same PUT request is called with status="published" when you click the "Update" button. There is no API for differential updates, so you must send the full set every time.

4 traps that return a 500 error with an empty body in the publish payload

I ran into issues several times when calling PUT /api/v1/text_notes/{id} in my implementation, where the response body would be completely empty and only a 500 error would return. After capturing the payload actually sent by the Web editor using Tampermonkey and comparing them, I found that the following 4 causes lead to a 500 error without an error message when deserialization or validation fails on the note side.

  • ** Do not leave image_keys as an empty array**: List the <key> from <img src=".../img/<key>.<ext>"> within free_body / pay_body in the order they appear. The Web editor updates this array every time an image is uploaded, and sending it empty causes the note-side integrity check to fail.

  • ** Do not put null in lead_form / line_add_friend**: If it is null, the note deserializer will crash. Even if unused, include an empty object like {"is_active": false, "consent_url": ""} / {"is_active": false, "keyword": "", "add_friend_url": ""}.

  • ** body_length is the visible text length, not the HTML length**: free_body.length + pay_body.length results in the byte count including HTML tags, but note expects the same value as the character counter in the editor (the number of Unicode code points after removing tags). If this is off, you get a 500.

  • ** Do not send an empty string for slug**: The default when unspecified is the slug-<note_key> pattern used by the Web editor, which is safe. Sending an empty string breaks URL behavior. Note that while the slug field itself is accepted at the API layer and saved to the DB, it has no effect on URL routing: note_url / OG / canonical / shared URLs are all fixed based on the key ( n... ), and URLs like https://note.com/<user>/n/<custom-slug> will result in an HTTP 404. It is effectively a vestigial field from the old specification.

Since note does not return error details, capturing the Web payload from a real device and filling in the differences was the fastest way to resolve this.

circle_permissions shape (Membership-only publication)

The array elements of circle_permissions are in the {kind, keys} format, and you switch between 2 modes using the kind value.

  • Public to all members: {"kind": "circle", "keys": ["<circle_key>"]}

  • Limited to specific plans: {"kind": "circle_plan", "keys": ["<plan_key>", ...]}

When specifying multiple plans, use the format where you list them in the keys array of the same element ( {"kind":"circle_plan","keys":["plan_a","plan_b"]} ), rather than increasing the number of elements. Note that there are no fields named circle_key (singular) or circle_plan_key (singular), and you must always pass an array for keys (plural).

The circle key and plan key can be obtained from the response of GET /api/v3/memberships/circle_permissions ( data[].circle.key / data[].circle_plans[].key ).

You can remove existing membership associations by republishing with circle_permissions: [] (an empty array) (a common technique when you want to avoid the unpublish constraints mentioned later).

POST /api/v2/notes/{key}/change_status

An endpoint for returning a published article to draft (unpublish). The body is {"status":"draft"}. This is one-way only from published to draft; calling it for draft to published will be rejected with a 403.

Articles falling into the following categories will be rejected with HTTP 403, and the status will not change (the response body's data field will contain a message explaining the reason for rejection):

  • (a) Paid articles

  • (b) Articles that have been sold as paid articles in the past

  • (c) Membership benefit articles / Articles added to benefit magazines

  • (d) Articles that have been added to paid magazines / subscription magazines

(c) can be passed by re-publishing with ** circle_permissions: [] to remove the membership association, and then unpublishing**. (a) and (d) should theoretically pass if the corresponding fields are removed, but (b) is history-based validation and is considered irreversible (unverified).

Even in cases where unpublish fails, it is possible to perform a soft delete directly via DELETE /api/v1/notes/{numeric_id}, and this method does not have these restrictions (it transitions to status=deleted without issues, even for articles associated with memberships).

DELETE /api/v1/text_notes/draft_delete?id={numeric_id}

Deletes an article in draft state. It cannot be used for articles in published state.

DELETE /api/v1/notes/{numeric_id}

Deletes a published article (soft delete). From the author's perspective, it remains as status: "deleted", but it becomes invisible to general users.

Note that this is under /api/v1/notes/... and not /api/v1/text_notes/...

Retrieve for editing: GET /api/v3/notes/{key}?draft=true

As mentioned earlier, this is the form used to retrieve the "latest diff" displayed on the edit screen. This is the endpoint called by the Web UI edit screen, where data.note_draft.body contains the full HTML and data.note_draft.separator contains the boundary UUID.

However, since the edit diff (note_draft) may disappear immediately after publishing, you may encounter a state where note_draft is null when re-editing.

List of Media APIs (Images / Attachments / Embeds)

There are three main types of media that can be inserted into note articles, and each uses a different endpoint.

Images: 2-stage upload

POST /api/v3/images/upload/presigned_post
→ data.action と data.post.* (S3 presigned form fields) が返る
POST <data.action> (= S3 のドメイン) multipart で file + presigned fields
→ 204

Ultimately, it is embedded in the body HTML in the form of <figure name=UUID id=UUID><img src="/https://assets.st-note.com/img/<KEY>" alt="..." width=W height=H></figure>.

Attachments: 1-stage direct upload

POST /api/v2/attachments/upload
multipart で { file: <binary>, file_name, note_key }
→ { attachment_key, filename, size, embedded_content_key, type: "attachment" } が返る

Instead of S3 presigned, it is POSTed directly to the note.com server. The note_key (the article's n... key) is required in the body, meaning attachments cannot be added until after the draft is reserved.

Eyecatch image (thumbnail)

POST /api/v1/image_upload/note_eyecatch
multipart で { note_id: <draft_id>, file: <binary>, width: <px>, height: <px> }
→ { data: { url: "https://assets.st-note.com/production/uploads/images/<id>/rectangle_large_type_2_<hash>.png" } }

Unlike images for the body, this is a 1-stage multipart upload to the note server, not a 2-stage upload. When the note_id (numeric draft id) is included in the form, it is directly associated with the draft on the server side, so there is no need to send the eyecatch field separately via draft_save. The response URL is in a different format from body images (/production/uploads/images/<id>/rectangle_large_type_2_<hash>.png), and multiple image sizes are generated on the server side.

Two behaviors that are easy to get stuck on:

  • **If the Content-Type (MIME type) is not explicitly specified for the multipart file part, a 500 error will be returned.** You must determine image/png, image/jpeg, image/gif, or image/webp from the file extension and include it in the Content-Type header.

  • ** note_id is a numeric id** (the id from the /api/v1/text_notes response). Entering a key in the n... format will result in a 400 error.

Because URLs are stored in different formats, it is difficult to standardize the regular expression for extracting <key> from <img>, so images in the body (<img src=".../img/<key>.<ext>">) and the eyecatch (<img src=".../production/uploads/.../<hash>.png" alt="eyecatch">) must be handled via separate paths.

Embeds (YouTube / X, etc.)

GET /api/v2/embed_by_external_api/check_type?url=<URL>
→ { data: { type: "..." | null } }
GET /api/v2/embed_by_external_api?url=<URL>&service=<TYPE>&embeddable_key=<NOTE_KEY>&embeddable_type=Note
→ { data: { key: "emb...", html_for_embed: "..." } }

There are three behaviors that are easy to get stuck on.

  • check_type may return null for major services like YouTube. In fact, it seems the Web UI side determines this first using URL pattern matching, so check_type plays a supplementary role. It is practical to maintain your own URL pattern matching for youtube.com / youtu.be / x.com / twitter.com / instagram.com / soundcloud.com / spotify.com / vimeo.com / tiktok.com / github.com/.../gist/...

  • The embeddable_type for embed_by_external_api is "Note" (not TextNote), and the embeddable_key is the n... key of the note being edited (not the numeric id). If this is not met, it will be rejected with a 400 "Bad Request" error.

  • **If you want to embed the URL of an article within note, specify service=external-article**. check_type returns null, but if you explicitly specify service as external-article, the embedded_content_key (emb...) and the html_for_embed for the "external article card" will be returned.However, this is in the OGP-based "external article card" format and is different from the native note embed actually generated by the Web UI See the next section for details.

Native embedding of articles within note (POST /api/v1/embed)

Apart from the above embed_by_external_api (v2),
it was discovered through real-device observation on 2026-07-11 that a separate v1-series endpoint is used only for embedding articles within note.

The compact card (<iframe class="note-embed">) often seen in note article lists and timelines, which displays a thumbnail, title, excerpt, number of likes, author, and post date, is different in both appearance and mechanism from the external-article OGP card.
When you select "+" -> "Embed" in the Web UI and paste the URL of an article within note, this was the request actually being sent.



POST /api/v1/embed  (multipart/form-data)
  url: <埋め込みたい note 記事の URL>
  height: 211
  embeddable_type: Note
  embeddable_key: <埋め込み先の note の key (n... 形式)>

The response differs from the v2 series in the envelope shape, as it includes an extra layer of nesting called embedded_content.

{
  "data": {
    "embed_to": null,
    "embedded_content": {
      "key": "emb16218f1081f7",
      "url": "https://note.com/<urlname>/n/<key>",
      "service": "note",
      "identifier": "<key>",
      "embeddable_type": "Note",
      "html_for_embed": "<iframe class=\"note-embed\" height=\"211\" ... src=\"https://note.com/embed/notes/<key>\"></iframe>"
    }
  }
}

If you use data.embedded_content.key as the embedded_content_key, it will be saved in the body HTML in the following format:
<figure data-src="<URL>" embedded-service="note" embedded-content-key="emb...">

Points observed so far:

  • **There is no point in sending this URL to GET /api/v2/embed_by_external_api/check_type**. The determination of an article within note seems to be handled on the frontend side by looking at the URL pattern (note.com/<urlname>/n/<key>) and routing directly to v1/embed


  • Magazine URLs (note.com/<urlname>/m/<key>) are excluded and are treated as external-article OGP cards as before

  • As far as I have observed, height was always fixed at 211. It has not been verified whether this is a value used on the server side or just a hint for client-side display

List of Attachment Retrieval APIs (*parts not covered in existing articles)

As mentioned above, attachments can be uploaded via POST /api/v2/attachments/upload, but there are also unofficial APIs for the corresponding download / extraction of attachment lists within an article, which are also barely mentioned in existing articles.

GET /api/v2/attachments/download/{hash}

An endpoint to download the actual attachment file. {hash} is a 32-character hexadecimal string, and the attachment_key from the successful upload response is used as is. If you hit it with cookie-based authentication, the Content-Type and Content-Disposition are returned as is, so you can save it with the filename using curl -OJ.

curl -OJ "https://note.com/api/v2/attachments/download/d980bdc8a6224c130dad35cc9df2abd8"

List of attachments in the article body

The "File Download" block inserted in the note editor ultimately appears in the article body HTML as a <figure> like the one below.

<figure name="UUID" id="UUID" embedded-service="attachment" embedded-content-key="emb...">
  <a href="/https://note.com/api/v2/attachments/download/<32hex>" rel="...">
    <strong>CreateArticles.zip</strong> 17.6 KB  ファイルダウンロードについて
    ダウンロード
  </a>
</figure>

In other words, if you want to get a "list of all attachments in an article," you don't need to call a dedicated API; you can just fetch the data.body from GET /api/v3/notes/{key} and extract the <figure embedded-service="attachment">. From each <figure>, you can retrieve the 32-hex hash from the <a href>, the filename from the <strong>, the size label immediately following the </strong> (e.g., "17.6 KB"), and the id attribute (block id).

Extracting the hash for the download URL can be done with a single regular expression as follows.

/\/api\/v2\/attachments\/download\/([0-9a-fA-F]{32})(?:[/?#]|$)/

If you send the extracted hash to HEAD /api/v2/attachments/download/{hash}, you can pick up the Content-Type and Content-Length before downloading the actual file (if HEAD is rejected, you can fall back to GET and discard the bytes themselves to achieve the same result). This is useful for cases where you want to crawl articles and only grasp the types and sizes of attachments.

Magazine API List (*Parts not covered in existing articles)

note magazines consist of APIs to "create, update, and delete your own magazines" and operations to "link articles to magazines." Regarding the latter, this investigation revealed that two routes currently exist: a dedicated endpoint ( POST /api/v1/our/magazines/{magazine_key}/notes ) and inclusion in the publish payload ( magazine_ids / magazine_keys in PUT /api/v1/text_notes/{id} ) (details below).

GET /api/v1/my/magazines

List of your own magazines. This is an endpoint that was not listed in ego_station's table either.

POST /api/v1/my/magazines

Creates a magazine. The payload items change depending on the sales mode.

Payload for free magazines:

{
  "name": "...",
  "description": "...",
  "status": "public",
  "price": 0,
  "subscribe": false,
  "categories": []
}

Payload for paid (single-sale) magazines (status="public", price>0, categories required):

{
  "name": "...",
  "description": "...",
  "status": "public",
  "price": 500,
  "subscribe": false,
  "categories": ["gadget"]
}

Payload for paid (subscription) magazines ( note Premium subscription required):

{
  "name": "...",
  "description": "...",
  "status": "public",
  "subscribe": true,
  "price": 100,
  "frequency": 1,
  "is_free_subscribe": true,
  "content": "...",
  "target_number": "10",
  "management_name": "...",
  "categories": ["it", "business"]
}

For subscriptions, 2 categories are required, frequency (number of monthly updates: 1 / 2 / 4 / 10 / 20 / 30) is also required, and you cannot press the create button on the UI unless you are subscribed to note Premium. After creation, the note management review process will run.

PUT /api/v1/our/magazines/{key}

Update the magazine itself. Note that the path is under /our/ (not /my/ for creation/deletion). Since the fields require a full payload, you must send name / description / price / status / is_immediate_charge in their entirety.

DELETE /api/v1/my/magazines/{key}

Delete a magazine. This is an immediate deletion and is an operation that can also be performed from the UI.

Linking articles to magazines: There are 2 routes

There are currently two API routes for the "add article to magazine" operation.

Route 1: Dedicated endpoint (linking per article)

POST /api/v1/our/magazines/{magazine_key}/notes
  body: { "note_id": <numeric note id> }
→ 201 { data: { status: "success", note_status: "published", limited_note_included_magazine_keys: [...] } }

DELETE /api/v1/our/magazines/{magazine_key}/notes/{note_key}
→ 200 { data: { status: "success", ... } }

The path takes the magazine's hex key (starting with m...), and note that it is slightly asymmetric: the note_id in the POST body is the article's numeric id, while the {note_key} in the DELETE path is the article's hex key (n...). As far as I have tested, any combination other than this (e.g., using the numeric magazine id in the path, putting the note_key in the POST body) was rejected with a 404/400 error.

ego_station's 2024 list also included an endpoint called POST /api/v1/our/magazines/{id}/notes, and in my initial investigation, I wrote in this article that "it currently returns 404 and does not work," but this was because I was hitting it by interpreting {id} as the magazine's numeric id. It is still active if you hit it with the hex key. I am leaving this here as a correction to the previous article.

Route 2: Included in the publish payload (linking as part of article editing)

This is the method of including magazine_ids: [<numeric>, ...] or magazine_keys: ["m...", ...] in the PUT /api/v1/text_notes/{id} payload during publish. You only need to send one of either magazine_ids or magazine_keys, and since it is a union operation, there is no problem using both together. Sending an empty array will remove the association.

"magazine_ids": [1818414],
"magazine_keys": ["md9e3a0d087f4"]

The request sent when you select "Add to Magazine" while editing an article in the Web UI is Route 2, and it is sent together as part of the publish flow.

Which one to use?

  • If you just want to add an already published article to a specific magazine later → Route 1 (easier because you don't have to reconstruct the publish diff information)

  • If you are also editing other fields of the article (title / body / hashtags, etc.) at the same time → Route 2 (it will be a full PUT anyway)

  • If you are linking multiple articles to one magazine in a batch process → Route 1 (simple)

Membership API List (*Parts not covered in existing articles)

note's membership feature is internally called "circle," and endpoints are aggregated under the /api/v2/circle/... path. This is an area that has hardly been touched upon in previous articles.

Important design constraints

  • Each user can only have one membership. POST /api/v2/circle is only possible the first time. The design does not include the key in the path and identifies the target via Cookie authentication

  • There is no API to delete the membership itself. You cannot return to a "not owned" state until you delete the account (name and description can be updated). Do not test this

  • Unlike magazines, linking an article to a membership is only possible via the circle_permissions field in the publish payload (PUT /api/v1/text_notes/{id}). As far as I have searched this time, there is no dedicated endpoint (like POST /api/v1/our/circles/.../notes). For the shape of circle_permissions, refer to the section around publishing ("Shape of circle_permissions" subsection)

GET /api/v2/circle/memberships/summaries

Returns a list of memberships you are involved in (joined + managing) in a single list. You can distinguish whether you are on the management side by checking summary.circle.isOwner in each entry.

GET /api/v2/circle?canva_id=true

Membership information as an owner. You can retrieve the key, name, description, boardKey (bulletin board key), etc.

POST /api/v2/circle

Creates a membership (one-time only). The body is a simple form: { "name": "...", "description": "...", "type": "circle" }.

To reiterate, this is an irreversible operation. Since there is no delete API, if you call it by mistake, it will remain permanently.

PUT /api/v2/circle

Updates the membership itself. The body sends all fields: { name, description, tmp_header_image_key, is_delete_header_image }.

GET /api/v2/circle/plans

List of your plans.

POST /api/v2/circle/plans

Creates a plan. The body fields are as follows.

{
  "name": "ベーシック",
  "description": "...",
  "membership_notes_enabled": true,
  "membership_magazines_enabled": false,
  "signup_enabled": false,
  "thanks_message": "",
  "benefit_appeal_texts": [],
  "circle_plan_apply_urls": [],
  "is_free_subscribe": false
}

Immediately after creation, it is in a price unset / recruitment off state. It is a two-step process where the price is set separately in the next PATCH.

PATCH /api/v2/circle/plans/{plan_key}

Updates a plan. Like magazines, it requires a full payload. You must send all the following fields every time.

{
  "name": "...",
  "description": "...",
  "benefit_appeal_texts": [],
  "signup_enabled": false,
  "thanks_message": "",
  "withdrawal_message": "",
  "membership_notes_enabled": true,
  "membership_magazines_enabled": false,
  "circle_plan_apply_urls": [],
  "is_board_enabled": true,
  "tmp_header_image_key": "",
  "price": 300,
  "subscribe_price_in_next_month": { "is_immediate_charge": true }
}

Since there is no partial update API, if you are calling it yourself, you must first retrieve the complete plan information via GET /api/v2/circle/plans, replace only the fields you want to change, and then PATCH.

POST /api/v2/circle/plans/{plan_key}/suspend

Suspends a plan (effectively a deletion). This is not an "immediate deletion" but continues until the end of the month. Because it involves billing and content viewing periods for existing members, the operation is that the request is completed and it stops at the end of the month.

On the UI, you must re-enter the plan name in the deletion confirmation modal (to prevent typos).

POST /api/v3/memberships/plans/{plan_key}/magazines

An endpoint to replace the "bonus magazines" of a plan. The body is { "magazine_keys": [...] }. You can unlink them by sending an empty array.

GET /api/v2/circle/members

List of members participating in your membership (owner's perspective).

GET /api/v3/memberships/{circle_key}/notes

List of articles published exclusively for members.

GET /api/v2/creators/{urlname}/circle

Retrieves the creator's membership from the perspective of a public page.

GET /api/v2/creators/{urlname}/circle/plans

List of plans from the perspective of a public page.

List of Bulletin Board APIs (board / included with membership)

Memberships come with a "member-only bulletin board" by default, which is also an independent API. Use the key obtained from circle.boardKey.

GET /api/v2/boards/{board_key}/posts

List of bulletin board posts. Takes queries such as order_key=sort_updated_at&order_param=desc&exclude_comments=true&per=3.

POST /api/v2/boards/{board_key}/posts

Create a post. The body is as follows.

{
  "title": "...",
  "body": "...",
  "read_permission": { "kind": "circle" }
}

read_permission.kind is "circle" (public to all members) or "plan" (filtered by plan_keys). The payload for the latter is { "kind": "plan", "plan_keys": ["..."] }.

DELETE /api/v2/boards/{board_key}/posts/{post_key}

Delete a post. The response is 204 No Content.

GET /api/v2/boards/{board_key}/pinned_post

Pinned post on the bulletin board. Returns 404 if there is no pinned post.

List of Social APIs (Likes / Comments / Follows)

POST/DELETE /api/v3/notes/{key}/likes

Like / Unlike.

GET /api/v3/notes/{key}/likes

A list of users who liked the article.

GET /api/v3/notes/{key}/note_comments

A list of comments on the article. Until spring 2026, this was GET /api/v1/note/{numeric_id}/comments, but currently, the v1 series always returns an empty array, and the actual UI has switched to the v3 note_comments.

Query parameters are page / per_page / order (newest default / oldest) / parent_key (for retrieving reply threads, the parent comment key in nc... format). Since the article key in n... format is taken directly in the path, there is no longer a need to look up the numeric id in advance.

The response is in the form of {current_page, next_page, total_count, data: [...]}, with the array of each comment contained in data. Note that unlike v1, data contains an array directly, not an object (if your v1 code looks at data.comments, this will be a breaking change). Each element has the following shape:

  • key — The comment key. **nc... format** (e.g., nc99c174b7175e, a different system from the article key's n...)

  • comment — The body as an AST (described below, nested root + p + text)

  • user — { key: 32hex, nickname, urlname, profile_image_url }. **The unique identifier is user.key**; the numeric id present in the old v1 is not returned in v3.

  • is_root — If false, it is a thread reply.

  • reply_count / latest_creator_reply / is_creator_replied / like_count / is_edited, etc.

POST /api/v3/notes/{key}/note_comments

Post a comment. Write operations (POST/PUT/DELETE) have three non-obvious requirements.

  1. **The X-Note-Client-Code header is mandatory**. The value is a 64-hex client identifier. The note Web UI saves the value distributed from the server during the initial SSR into localStorage["note-client-code"] and reuses it. When reproducing, sending two concatenated uuid v4s (32 hex × 2) worked. There seems to be no check other than format and length (based on real-device observation; official specifications have not been confirmed).

  2. **The comment field is an AST, not a plain string**. The shape is { "type": "root", "children": [ { "type": "element", "tag_name": "p", "children": [ { "type": "text", "value": "body text" } ] } ] }. Sending a plain string will be rejected with a 500 ("unexpected error"). Paragraph breaks are represented by lining up <p> elements in children.

  3. **acknowledgement: false is mandatory**. Include it at the top level of the body. Omitting it results in a 500.

When posting a reply, add parent_key (the nc... format key of the parent comment) to the body.

PUT /api/v3/notes/{key}/note_comments/{comment_key}

Edit an existing comment. The body is { "comment": <AST> }. The X-Note-Client-Code header is mandatory, just like with POST. acknowledgement and parent_key were not required.

DELETE /api/v3/notes/{key}/note_comments/{comment_key}

Delete comment. The end of the path takes a comment_key in the format of nc... (the key field of each element in the list retrieval response, e.g., nc99c174b7175e) rather than a numeric comment_id. The X-Note-Client-Code header is required.

POST/DELETE /api/v3/users/{user_id}/following

Follow / Unfollow. The path is numeric user_id. To look up an ID from a urlname, check data.id in the response from GET /api/v2/creators/{urlname}.

Differences from existing articles (Corrections)

Among the articles I referenced, there were several endpoints that no longer work on the current note platform.

GET /api/v2/hashtags (Hashtag list)

Although it is listed in ego_station's table, it currently returns a 404. It is possible that hashtag list retrieval has been integrated into a different path or discontinued. Retrieving information for a single tag (GET /api/v2/hashtags/{tag}) remains active.

GET /api/v1/followings/{userId}/list / GET /api/v1/followers/{userId}/list

This is the following list API listed in ego_station's table, but it currently returns a 404. The result was the same whether passing a urlname or a numeric ID.

Trying GET /api/v3/users/{userId}/followings returns a 403 (forbidden), which seems to be blocked by permission controls (even when passing one's own ID, it returns 403). I have not been able to identify the correct following list API path this time.

POST /api/v1/our/magazines/{id}/notes (Add article to magazine) — Note the interpretation of {id}

When calling this 'API to add an article to a magazine' listed in ego_station's table by interpreting {id} as the magazine's numeric ID, it returns a 404, but it still works normally if you enter the magazine's hex key (m...) (see 'Linking articles to magazines' above). The {id} label is simply misleading, and the endpoint itself is still active. I initially wrote 'currently 404' in this article, but this was a misinterpretation of the path during my verification, which I have corrected separately.

GET/POST/DELETE /api/v1/note/{numeric_id}/comments (Comment API v1 series) — Effectively discontinued as of Spring 2026

This comment API, which is listed in almost all preceding articles including ego_station's table, changed its behavior around Spring 2026 and now always returns an empty array. It is a silent failure that returns 200 with an empty array ([]) rather than a 404 or 410, so it is difficult to notice as the request appears to be successful.

Observing the path called by the actual note Web UI, it has migrated to GET /api/v3/notes/{key}/note_comments (see the v3 subsection of the 'Social' section above for details). Requirements have also changed significantly, such as the need for the X-Note-Client-Code header and the comment AST format for write operations, not just the path.

When referring to preceding articles written in 2024-2025, you must read the comment API as the v3 series.

Common Pitfalls

Finally, I will summarize the behaviors I encountered when calling the endpoints in sequence.

Many PATCH / PUT requests require a 'full payload'

Publish ( PUT /api/v1/text_notes/{id} ), magazine updates ( PUT /api/v1/our/magazines/{key} ), and plan updates ( PATCH /api/v2/circle/plans/{plan_key} ) are all designed to send all fields every time rather than performing differential updates.

For example, even if you only want to change the magazine description, if you PUT only { "description": "new description" }, it will be rejected with a 400 "name is missing" error. A round trip is mandatory where you first re-fetch the current state via GET, replace only the fields you want to change, and then re-send the full payload.

Disappearance of note_draft after publication

I observed that data.note_draft, which is used for editing paid articles, becomes null for a while immediately after publishing. If you try to re-edit it immediately, you cannot retrieve the information necessary to restore free_body and pay_body (the full body text and separator UUID), making it impossible to construct a re-PUT request.

Specifically, the body of GET /api/v3/notes/{key} only contains the free portion, and both free_body and pay_body become null. Even when adding ?draft=true, there were cases where note_draft itself was null.

Since note_draft sometimes reappears after a short time, it is safer to include a path like "wait a little and retry if note_draft is null" when building automation for editing.

Inline HTML tags allowed by the note editor are quite limited

Wondering what inline tags would pass in the body HTML, I tried <code> <em> <strong> <i> <u> <s> <mark> <kbd> <tt> <samp> <var> <small> <sub> <sup> <abbr> <span> in order, and it became clear that the note server-side sanitizer strips them on the fly via POST /api/v1/text_notes/draft_save.

  • Surviving inline tags: <em> / <strong> / <s> / <code>

  • Deleted tags: <i> / <u> / <mark> / <kbd> / <tt> / <samp> / <var> / <small> / <sub> / <sup> / <abbr> / <span> (entirely removed along with class and data attributes)

  • Sending <div> breaks the paragraph structure: It is split into </p> + contents of div + new <p></p> after being received, which collapses the original <p> block.

Furthermore, <code> has a unique trap: it is temporarily retained when sent via API, but it is deleted along with the <code> tag when re-saved in the note Web editor. Since putting it inside a list item like <li><p><code>...</code></p></li> also breaks the rendering on the web, the safe approach if you want to use inline code is to fall back to plain text without outputting <code> (you could use <em>, but it becomes indistinguishable from italic).

The <span> family is similar; since everything is stripped even if you add classes or styles, it cannot be used as an insertion point for CSS styling.

Paid articles cannot be unpublished

For "articles that have a sales history as a paid article in the past," even if you send status: "draft" via POST /api/v2/notes/{key}/change_status, it will be rejected with a 403 "Cannot revert to draft." If you want to change the price, you have no choice but to recreate the article, and if you want to withdraw it from publication, the only means is a soft delete ( DELETE /api/v1/notes/{numeric_id} ).

Irreversibility of membership creation

I repeat, POST /api/v2/circle (membership creation) is an irreversible operation with 1 per user and no deletion API. If you create one by testing, you cannot return to a "state of not having a membership" until you delete the account.

Fortunately, the name and description can be updated after the fact, so if you accidentally create one, the realistic solution is to change the name to something like "Under Preparation" and leave it in a state where no plans are created (signup_enabled=false / no plans at all).

Plan suspension remains until the end of the month

POST /api/v2/circle/plans/{plan_key}/suspend is not an "immediate deletion," but remains as a plan scheduled to be suspended until the end of the month. Because it involves billing and viewing periods for existing members, it maintains its state for up to one month from the suspension request.

Two categories are required for subscription magazines

When creating a paid (subscription) magazine, the categories array must contain two items (on the UI, two selects are displayed). The minimum number varies by sales mode: paid (single) requires at least one, while free magazines can have zero.

Although you can notice this via the disabled state on the UI, if you hit the API directly, you will only find out through an error response, so you must specify at least two when creating a subscription magazine.

Tips for Utilization

The path to tracing note's unofficial API (overviewing articles / magazines / memberships / statistics / overall structure)
The path to tracing note's unofficial API (overviewing articles / magazines / memberships / statistics / overall structure)

Looking at the list of endpoints, the processes that note's Web UI performs in the background (two-stage image upload, handling note_draft when editing paid articles, assembling full payload PATCH requests, maintaining Cookie authentication, etc.) involve quite a few steps. If you hit them one by one with curl, you end up rewriting the authentication and body HTML assembly every time, which is a significant amount of work even for personal use.

For practical convenience, I have built and used the following configuration locally.

  • Local HTTP server written in Rust (resident daemon): A layer that encapsulates HTTPS communication to note, persistence of the _note_session_v5 Cookie, two-stage upload of images/attachments, Markdown to block structuring, and the editing cycle for paid articles via note_draft. This allows the script side to simply make HTTP requests to localhost.

  • Thin SDK in Python / Node: A runtime-dependency-free client that only hits the daemon. Since Python can be implemented with urllib and Node with standard fetch, it doesn't become heavy even if you include it in one-time scripts every time.

In short, this is a split where 'HTTP to note' is encapsulated in a Rust daemon, and AI Agents or CLI scripts only hit a thin local API. Automatic block conversion from Markdown input (e.g., ![alt](file:///...) to local image upload, standalone URL paragraphs to embeds, [label](file:///...) to attachments) is also handled collectively on the daemon side.

These tools are for personal use only and I have no plans to release them. This is because it is difficult to take responsibility for distributed software, given that note's unofficial API is in a gray zone regarding terms of service and robots.txt, should be used at low frequency and minimally, and breaks frequently due to specification changes.

If you want to build a similar layer for the same requirements yourself, I think it would be very convenient for personal use to start from the endpoint map in this article and try the split of 'moving Cookies, image uploads, and block formatting to a daemon, and making the SDK a thin HTTP client that hits localhost'.

By the way, this article itself was written and posted using the daemon + SDK mentioned above. I write Markdown in Obsidian, and by simply hitting create_draft → save_draft → publish_draft with the Python SDK, the note draft is assembled, and the process is completed locally up to the point of publication. The writing experience is almost the same as a regular Markdown blog, and the daemon handles the note-specific block structure (paragraphs with block IDs, figures, separators, etc.) in the background.

It has become a recursive dog-fooding of 'writing tools to write articles, and writing articles with those tools,' but it also means that the unofficial API is usable enough to actually run such automation.

Conclusion

I have summarized the current state of the APIs that note's Web UI hits, to the extent that I have observed. While centered on the /api/v3/... series, write-related APIs are scattered across /api/v1/... and /api/v2/..., and my impression is that the naming convention is not very consistent. In particular, the distinction between /my/... and /our/... for magazines, and the fact that memberships are placed under /circle/..., were structures that were difficult to notice without observation.

Since it is a premise that unofficial APIs will be blocked by specification changes, the content of this article may also become outdated over time. In fact, some of the paths in ego_station's list from two years ago were already not working. If you refer to this article, please use it after verifying the operation on an actual device.

I would like to keep in mind to use it with moderation and low frequency, without forgetting respect for the operations of the note management team.

Update History

  • 2026-08-02: Added GraphQL API ( graphql.note.com )

    • While crawling the article list, I confirmed a phenomenon where GET /api/v2/creators/{urlname}/contents does not return some existing articles. The cause was not a pagination bug, but a specification where articles with exclude_from_creator_top: true ("Hide from creator page" setting) are excluded from the list.

    • The note.com web frontend has already migrated article list retrieval to GraphQL. I confirmed two queries: CreatorAllNotesPageQuery, which corresponds to the new arrivals list, and CreatorArchivesPageQuery, which is used for monthly archives. The latter ignores exclude_from_creator_top and returns all items.

    • Added the above notes and a GraphQL section to the "Article-related API List".

    • Created a separate heading for GET /api/v2/creators/{urlname}/archives (the REST API for annual post count summaries used by the monthly archive top page). When combined with the GraphQL CreatorArchivesPageQuery, you can retrieve all items without omissions or redundancies.

  • 2026-07-11: Discovered that native embedding of articles within note is handled via POST /api/v1/embed (v1).

    • In the "Embedding (YouTube / X, etc.)" section, I wrote that embedding articles within note could also be substituted with embed_by_external_api (v2) using service=external-article, but this only results in an OGP-based "external article card." The compact <iframe class="note-embed"> format actually generated by the Web UI goes through a different endpoint than the v2 series: POST /api/v1/embed ( embeddable_type: Note , height: 211 ).

    • The response envelope also differs from v2, being nested one level deeper under data.embedded_content.key.

    • Since observations using only the Chrome DevTools network panel were missing data, I converted the fetch/XHR hook from the "Observation Methods" section into a Tampermonkey userscript, kept it resident on note.com / editor.note.com, and captured the data while actually operating the "Embed" button in the Web UI.

  • 2026-05-22: Reflected breaking changes for POST /api/v1/sessions/sign_in

    • As of late May, the ** g_recaptcha_response (reCAPTCHA v3 token) field is now mandatory in the payload. If called without a token, it returns a 2xx status, but the _note_session_v5 Cookie is not issued.

    • As a result, custom scripts logging in with only email + password have effectively been blocked.

    • Added a note box to the Authentication API section, stating that a migration to browser Cookie import (libsecret + AES-128-CBC v11 decryption) is required.

  • 2026-05-17: Minor rewrite of titles and body headings (no changes to content).

  • 2026-05-16: Complete rewrite of the Comment API

    • v1 (GET/POST/DELETE /api/v1/note/{numeric_id}/comments series) has started returning empty arrays since spring 2026, and the actual UI has migrated to v3 (/api/v3/notes/{key}/note_comments). This article has been updated accordingly.

    • For write operations (POST/PUT/DELETE), three items are mandatory: the X-Note-Client-Code header (64 hex), the AST format for the comment field ({type:"root", children:[...]} ), and acknowledgement: false.

    • Confirmed the existence of PUT /api/v3/notes/{key}/note_comments/{comment_key} for editing (this was not confirmed in v1).

    • The deletion path takes a comment_key in the nc... format, not the numeric comment_id (this is a different system from the n... format used for article keys).

    • Adding parent_key to the POST body results in a reply post.

    • Note on response format: data is not an object but contains an array directly (code using data.comments for v1 will break). The unique identifier for the user object is user.key (32 hex); the numeric id present in the old v1 is not returned in v3 (if your aggregation script uses it to determine commenter identity, you must adapt it to use user.key).

    • Additionally, added an extended version for observing write APIs hook (which stores headers in localStorage) to the "Observation Methods" section.

  • 2026-05-14: Added discoveries regarding memberships, magazines, and hashtags

    • circle_permissions shape (2 modes: {kind: "circle" | "circle_plan", keys: [...]})

    • Correction to magazine article addition API: POST /api/v1/our/magazines/{key}/notes is still active if called with a hex key (returns 404 with a numeric id). Also noted the DELETE side ( /{note_key} at the end of the path)

    • GET /api/v3/hashtags/{tag}/notes (list of articles linked to a tag, order=popular|new|hot, 50 items per page)

    • Added note that GET /api/v2/hashtags/{tag} includes the relatedHashtags field

    • draft_save silently ignores publish metadata such as hashtags / magazine_ids / circle_permissions (returns 200 but does not persist)

    • The vestigial nature of the slug field (accepted by the API but does not affect URL routing)

    • Details on the 4 categories of unpublish 403 errors (paid / sales history / membership / paid magazine) and workarounds (re-publish with circle_permissions: [] or direct DELETE)

  • 2026-05-13: Added findings on publish-related behavior observed during verification and peripheral discoveries

    • The 4 traps of PUT /api/v1/text_notes/{id} that return a 500 error with an empty body (image_keys / lead_form / line_add_friend / body_length / slug)

    • Endpoint for header images: POST /api/v1/image_upload/note_eyecatch (MIME type specification required)

    • Verification of inline HTML tags allowed by the note sanitizer (only <em> / <strong> / <s> / <code> survive; <code> is also removed upon re-saving on the web)

    • Added search API sort parameters (popular / hot / new) and context values (hashtag / circle / noteForSale)

  • 2026-05-12: Added the attachment retrieval API (GET /api/v2/attachments/download/{hash}) and the procedure for extracting the attachment list from <figure embedded-service="attachment"> within the article body

  • 2026-05-11: First edition published

いいなと思ったら応援しよう!