NoteStream API
Base URL: https://api.notestream.cloud
All paths below are relative to this base URL. For example, POST /search means
POST https://api.notestream.cloud/search.
There is no separate AI namespace — agents call the same endpoints as the app. Every endpoint accepts either a user API token or a session JWT:
X-API-Key: your-api-key-here
Authorization: Bearer <jwt>
API keys can be created from Settings > API Keys in the NoteStream app. A token maps to a real user, so a request sees exactly what that user can see (their own notes plus everything shared with them via direct shares, hashtags, groups, and share links).
A note and its content are separate entities with a 1:1
mapping. A note holds metadata (ID, author, sharing, timestamps); note content holds the
document body as ProseMirror JSON. Every note has exactly one content record, and they share
the same ID. The root node is always { type: "doc", content: [...] }.
Search notes
Search across every note the caller can see. The request body is a filter AST; the only
required field is visibility.
POST /search
Request body
{
"ast": {
"visibility": "my-world",
"text": "meeting"
},
"page": 1,
"pageSize": 25
}
| Field | Type | Required | Description |
|---|---|---|---|
ast |
object | Yes | Filter AST (see below). |
page |
number | No | Page number (starts at 1). Defaults to 1. |
pageSize |
number | No | Results per page. Defaults to 25, max 100. |
shareCodes |
string[] | No | Share-link codes the caller has opened, to include link-shared notes. Max 20. |
Filter AST fields:
| Field | Type | Required | Description |
|---|---|---|---|
visibility |
string | Yes |
One of "public", "shared-with-me",
"all", "my-world".
"my-world" = the caller's own notes plus notes shared with
them.
|
text |
string | No | Case-insensitive substring match on note text. |
author |
object | No |
{ "op": "me" }, or
{ "op": "is", "ids":
["<email-or-userId>"] }.
|
hashtags |
array | No | [{ "hashtag": "roadmap" }]. |
mentions |
array | No | [{ "handle": "john" }]. |
sortBy |
string | No |
"newest", "oldest",
"streamOrder", "lastUpdated",
"relevance".
|
Response
{
"notes": [
{
"id": "0192d4e5-7a8b-7c6d-9e0f-1a2b3c4d5e6f",
"author": {
"id": "user-uuid",
"firstName": "Ada",
"lastName": "Lovelace",
"email": "ada@example.com",
"profilePicture": null
}
}
],
"totalCount": 250,
"page": 1,
"pageSize": 25
}
Results are note IDs (with author info), not snippets — fetch a note's body with
GET /content/:id.
Errors
| Status | Description |
|---|---|
| 401 | Missing or invalid credentials |
| 422 | Invalid request body / filter AST |
Get a note
Retrieve the metadata of a note the caller owns.
GET /notes/:id
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id |
path | string | The note ID (UUID) |
Response
{
"id": "0192d4e5-7a8b-7c6d-9e0f-1a2b3c4d5e6f",
"authorId": "user-uuid",
"isShared": false,
"pinnedPosition": null,
"folderId": null,
"sourcePlatform": null,
"sourceLink": null,
"deletedAt": null,
"createdAt": 1711900800000,
"updatedAt": 1711900800000
}
Errors
| Status | Description |
|---|---|
| 401 | Missing or invalid credentials |
| 404 | Note not found or not owned by the caller |
Create a note
Create a new note from ProseMirror JSON content. The note is assigned to the caller.
POST /notes
Request body
{
"content": {
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [{ "type": "text", "text": "A brand new note" }]
}
]
},
"isShared": true,
"anonymousRole": "VIEWER"
}
| Field | Type | Required | Description |
|---|---|---|---|
content |
object | Yes | ProseMirror document with type: "doc". |
isShared |
boolean | No |
Share the note with anonymous link access on creation. Defaults to false.
|
anonymousRole |
string | No |
"VIEWER" or "EDITOR". Role for anyone
with the link. Defaults to "VIEWER".
|
groupId |
string | No |
Create the note directly inside this group. Requires CONTRIBUTOR or
higher membership in the group (else 403).
|
pinnedPosition |
string | No | Fractional-index position to pin the note. Omit to leave unpinned. |
sourcePlatform |
string | No |
Where the note came from, e.g. "X". Rendered as a "via X
↗" chip. Max 64 chars.
|
sourceLink |
string | No |
Permalink for the source chip. Must be an http(s) URL, max 2048 chars.
|
Response
Returns the full note object (same shape as GET /notes/:id).
Errors
| Status | Description |
|---|---|
| 401 | Missing or invalid credentials |
| 403 | groupId given but you lack posting permission in that group |
| 422 | Invalid ProseMirror JSON structure |
Read note content
Retrieve a note's content as ProseMirror JSON.
GET /content/:id
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id |
path | string | The note ID (UUID) |
Response
{
"content": {
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [{ "type": "text", "text": "Hello world" }]
}
]
}
}
If the note has no content, returns an empty document:
{ "content": { "type": "doc", "content": [{ "type": "paragraph" }] } }
Errors
| Status | Description |
|---|---|
| 401 | Missing or invalid credentials |
| 404 | Note not found or not owned by the caller |
Replace note content
Replace the entire content of an existing note with new ProseMirror JSON.
PUT /content/:id
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id |
path | string | The note ID (UUID) |
Request body
{
"content": {
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [{ "type": "text", "text": "Updated content" }]
}
]
}
}
The content field must be a valid ProseMirror document with
type: "doc" and a content array.
Response
{ "success": true }
Errors
| Status | Description |
|---|---|
| 401 | Missing or invalid credentials |
| 404 | Note not found or not owned by the caller |
| 422 | Invalid ProseMirror JSON structure |
Update note sharing
Set a note's sharing entries. Supports anonymous (anyone-with-link) and per-email sharing.
PUT /notes/sharing/:id
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id |
path | string | The note ID (UUID) |
Request body
{
"entries": [
{ "principalType": "ANONYMOUS", "principal": "anonymous", "role": "VIEWER" }
]
}
| Field | Type | Required | Description |
|---|---|---|---|
entries |
array | Yes | The full set of sharing entries for the note. |
entries[].principalType |
string | Yes |
"ANONYMOUS" (anyone with the link) or
"EMAIL".
|
entries[].principal |
string | Yes |
"anonymous" for ANONYMOUS, otherwise the member
email.
|
entries[].role |
string | Yes | "VIEWER" or "EDITOR". |
isPublishedToGlobalStream |
boolean | No | Publish the note to the public global stream. |
Errors
| Status | Description |
|---|---|
| 401 | Missing or invalid credentials |
| 404 | Note not found or not owned by the caller |
| 422 | Invalid request body |
Note: When a note is shared anonymously it becomes accessible via a
shareable link. The URL removes dashes from the note ID:
https://notestream.cloud/shared/note/{noteId without dashes}. For example, note
0192d4e5-7a8b-7c6d-9e0f-1a2b3c4d5e6f becomes
https://notestream.cloud/shared/note/0192d4e57a8b7c6d9e0f1a2b3c4d5e6f.
Update note source
Set or clear a note's source-provenance metadata, without touching its content. The app
renders these as a "via <sourcePlatform> ↗" chip linking to
sourceLink.
PATCH /notes/:id/source
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id |
path | string | The note ID (UUID) |
Request body
{
"sourcePlatform": "X",
"sourceLink": "https://x.com/jack/status/20"
}
| Field | Type | Required | Description |
|---|---|---|---|
sourcePlatform |
string | null | No | Platform label, max 64 chars. null clears it. |
sourceLink |
string | null | No |
Permalink, must be an http(s) URL, max 2048 chars.
null clears it.
|
At least one of the two fields must be present. An omitted field is left unchanged.
Response
Returns the full note object (same shape as GET /notes/:id).
Errors
| Status | Description |
|---|---|
| 400 | Invalid payload, or neither field provided |
| 401 | Missing or invalid credentials |
| 404 | Note not found or not owned by the caller |
Import saved items
Ingest a batch of saved/bookmarked items from an external platform as notes. The caller fetches the items itself (Notestream never receives platform credentials) and POSTs the normalized batch here; the server owns dedupe, note construction, and the resume cursor.
POST /imports/:platform GET /imports/:platform
Parameters
| Name | In | Type | Description |
|---|---|---|---|
platform |
path | string | x or instagram |
Request body (POST)
{
"sourceId": "x-session",
"cursor": "opaque-resume-token",
"items": [
{
"externalId": "20",
"url": "https://x.com/jack/status/20",
"text": "just setting up my twttr",
"authorHandle": "jack",
"authorName": "jack",
"createdAt": 1143843780000,
"collections": ["Trip Ideas"],
"mediaUrls": []
}
]
}
| Field | Type | Required | Description |
|---|---|---|---|
sourceId |
string | Yes |
Which adapter produced the batch: x-session, x-api,
instagram-session, or instagram-dyi. Max 64 chars.
|
items |
array | Yes | Up to 200 normalized items. |
cursor |
string | null | No |
Resume token to persist after this batch. Omit to leave the stored cursor untouched;
null clears it and forces a full re-read next run. Max 4096 chars.
|
items[].externalId |
string | Yes | Platform-native id. This is the dedupe key, so re-POSTing a batch is a safe no-op. Max 256 chars. |
items[].url |
string | Yes |
Canonical permalink (http(s), max 2048 chars). Stored as the note's
sourceLink.
|
items[].text |
string | Yes | Item body (tweet text or caption). May be empty; max 40,000 chars. |
items[].authorHandle |
string | No | Author handle without a leading @. Max 128 chars. |
items[].authorName |
string | No | Author display name. Max 256 chars. |
items[].createdAt |
number | No | When the item was created on the platform (epoch ms). Accepted but not yet applied to the note's timestamps. |
items[].collections |
array<string> | No |
Up to 20 collection/folder names. Each becomes a slugified hashtag on a new note, or
is merged into an already-imported note when missing ("Trip Ideas 🇯🇵"
→ #trip-ideas).
|
items[].mediaUrls |
array<string> | No | Up to 10 media URLs. Accepted and validated but not yet rendered in the note. |
Each new imported item becomes a note containing the item text, an attribution line with the
permalink, and the collection hashtags, with sourcePlatform /
sourceLink set for the source chip. If an item is already in the import ledger
and the request supplies new collections, the server appends missing collection hashtags to
the existing note through the collab document path instead of creating a duplicate.
Response (POST)
{
"imported": 1,
"skipped": 0,
"updated": 0,
"noteIds": ["0192d4e5-7a8b-7c6d-9e0f-1a2b3c4d5e6f"]
}
updated counts already-imported notes that gained collection hashtags.
skipped counts items already present in this user's import ledger for the
platform with nothing to merge. noteIds lists the notes created, in item order.
Response (GET)
{
"platform": "x",
"sourceId": "x-session",
"cursor": "opaque-resume-token",
"lastSyncedAt": 1711900800000,
"importedCount": 412
}
sourceId, cursor, and lastSyncedAt are
null until the first successful ingest.
Errors
| Status | Description |
|---|---|
| 400 | Invalid import payload |
| 401 | Missing or invalid credentials |
| 404 | Unknown import platform |
Create a group
Create a new group. The caller becomes its OWNER member.
POST /groups
Request body
{
"name": "Design Team",
"description": "Where the design crew collaborates"
}
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | Group name (1–120 chars). |
description |
string | No | Optional description (max 2000 chars). |
Response
Returns the created group:
{
"group": {
"id": "0192d4e5-7a8b-7c6d-9e0f-1a2b3c4d5e6f",
"slug": "design-team-a1b2c3d4",
"name": "Design Team",
"description": "Where the design crew collaborates",
"logo": null,
"ownerId": "user-uuid",
"shareCode": null,
"shareCanEdit": false,
"createdAt": "2026-06-27T16:00:00.000Z",
"updatedAt": "2026-06-27T16:00:00.000Z"
}
}
Errors
| Status | Description |
|---|---|
| 401 | Missing or invalid credentials |
| 422 | Invalid request body |
List groups
List the groups the caller belongs to, each with their role and member count.
GET /groups
Response
{
"groups": [
{
"id": "0192d4e5-7a8b-7c6d-9e0f-1a2b3c4d5e6f",
"slug": "design-team-a1b2c3d4",
"name": "Design Team",
"description": null,
"logo": null,
"ownerId": "user-uuid",
"shareCode": null,
"shareCanEdit": false,
"createdAt": "2026-06-27T16:00:00.000Z",
"updatedAt": "2026-06-27T16:00:00.000Z",
"myRole": "OWNER",
"memberCount": 3
}
]
}
Errors
| Status | Description |
|---|---|
| 401 | Missing or invalid credentials |
Add group members
Add or update members of a group by email. Owner-only — the caller must own the group. Membership is keyed by email, so an invitee who has not registered yet resolves to their account once they sign up with that email.
POST /groups/:id/members
Parameters
| Parameter | Type | Description |
|---|---|---|
id |
string | The group's id (from create / list). |
Request body
{
"members": [
{ "email": "alice@example.com", "role": "CONTRIBUTOR" },
{ "email": "bob@example.com", "role": "VIEWER" }
]
}
| Field | Type | Required | Description |
|---|---|---|---|
members |
array | Yes | 1–100 entries. |
members[].email |
string | Yes | Member email. |
members[].role |
string | Yes |
One of OWNER, EDITOR, CONTRIBUTOR,
VIEWER.
|
Response
Returns the upserted member rows:
{
"members": [
{
"id": "0192d4e5-7a8b-7c6d-9e0f-1a2b3c4d5e6f",
"groupId": "0192d4e5-7a8b-7c6d-9e0f-1a2b3c4d5e70",
"principal": "alice@example.com",
"role": "CONTRIBUTOR",
"createdAt": "2026-06-27T16:00:00.000Z",
"updatedAt": "2026-06-27T16:00:00.000Z"
}
]
}
Errors
| Status | Description |
|---|---|
| 401 | Missing or invalid credentials |
| 404 | Group not found, or you are not its owner |
| 422 | Invalid request body |
ProseMirror node types
Notes support the following node types in the content array:
| Node type | Description | Example |
|---|---|---|
paragraph |
A block of text |
{ "type": "paragraph", "content": [{
"type": "text", "text": "..." }] }
|
heading |
Heading (attrs: level 1-6) |
{ "type": "heading", "attrs": { "level": 1
}, "content": [...] }
|
bulletList |
Unordered list containing listItem nodes |
|
orderedList |
Ordered list containing listItem nodes |
|
listItem |
List item wrapping paragraph or nested list | |
taskList |
Todo list containing taskItem nodes |
|
taskItem |
Todo item (attrs: checked boolean) |
{ "type": "taskItem", "attrs": { "checked":
false }, "content": [...] }
|
image |
Image (attrs: src) |
{ "type": "image", "attrs": { "src":
"https://..." } }
|
audio |
Audio recording (attrs: src, audioId) |
|
relation |
Link to another note (attrs: id, label) |
{ "type": "relation", "attrs": { "id":
"note-uuid", "label": "My Note" } }
|
mention |
Inline @mention (attrs: id, label, type) |
{ "type": "mention", "attrs": { "id":
"entity-name", "label": "John" } }
|
Mentions are atomic inline nodes rendered as pills (e.g. @John). The
id serves as the mention identifier and label is the display text.
The type attribute defaults to "text" for free-text
mentions.
Text marks
Text nodes can have marks for inline formatting:
| Mark type | Description |
|---|---|
bold |
Bold text |
italic |
Italic text |
strike |
Strikethrough |
underline |
Underline |
link |
Hyperlink (attrs: href) |
hashtagMark |
Hashtag (the text content includes the # prefix) |
Example: rich note
{
"content": {
"type": "doc",
"content": [
{
"type": "heading",
"attrs": { "level": 1 },
"content": [{ "type": "text", "text": "Meeting Notes" }]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Discussed the " },
{ "type": "text", "text": "roadmap", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": " for Q2." }
]
},
{
"type": "taskList",
"content": [
{
"type": "taskItem",
"attrs": { "checked": false },
"content": [
{
"type": "paragraph",
"content": [{ "type": "text", "text": "Follow up with design team" }]
}
]
}
]
}
]
}
}
Webhooks
Webhook subscriptions notify external systems when note content changes and the note's plain
text contains the configured trigger text. The default trigger text is @agent.
All webhook management endpoints accept either a JWT (Authorization: Bearer <jwt>) or a user API token (X-API-Key: <key>).
List webhooks
GET /webhooks
Response:
{
"subscriptions": [
{
"id": "0192d4e5-7a8b-7c6d-9e0f-1a2b3c4d5e6f",
"name": "Agent runner",
"url": "https://example.com/notestream",
"hasSecret": true,
"triggerText": "@agent",
"enabled": true,
"lastDeliveredAt": 1711900800000,
"lastDeliveryStatus": 200,
"lastDeliveryError": null,
"createdAt": 1711900800000,
"updatedAt": 1711900800000
}
]
}
Secrets are never returned. Use hasSecret to tell whether a subscription has
one.
Create a webhook
POST /webhooks
Request body:
{
"url": "https://example.com/notestream",
"name": "Agent runner",
"secret": "shared-signing-secret",
"triggerText": "@agent",
"enabled": true
}
| Field | Type | Required | Description |
|---|---|---|---|
url |
string | Yes | Delivery URL. Must use http or https. |
name |
string | No | Human-readable name. |
secret |
string | No | Shared HMAC secret, 8 to 512 characters. Never returned by the API. |
triggerText |
string | No | Text that must appear in the note. Defaults to @agent. |
enabled |
boolean | No | Whether to deliver events. Defaults to true. |
Production rejects localhost, loopback, link-local, and private literal IP URLs. Localhost
and loopback URLs are accepted only outside production when
ALLOW_LOCAL_WEBHOOK_URLS=true.
Response status: 201 Created, with the subscription object shown above.
Delete a webhook
DELETE /webhooks/:id
Response status: 204 No Content.
Delivery payload
When note content changes through collaboration, REST content sync, or AI note/content endpoints, NoteStream sends:
{
"eventId": "0192d4e5-7a8b-7c6d-9e0f-1a2b3c4d5e6f",
"event": "note.content.updated",
"noteId": "note-uuid",
"userId": "note-owner-user-uuid",
"source": "collab",
"triggerText": "@agent",
"subscriptionId": "subscription-uuid",
"idempotencyKey": "stable-sha256-key",
"contentHash": "sha256-of-plain-text",
"plainText": "@agent please summarize this note",
"occurredAt": "2026-06-20T10:30:00.000Z"
}
source is one of collab, content-push,
content-bulk-push, ai-content, or ai-note. The same
subscription, note, trigger text, and content hash produce the same
idempotencyKey, so unchanged matching content is delivered once.
Delivery headers
Every delivery includes:
| Header | Description |
|---|---|
X-NoteStream-Event |
Event name, currently note.content.updated. |
X-NoteStream-Delivery |
Delivery/event ID. |
X-NoteStream-Idempotency-Key |
Stable key for duplicate suppression. |
X-NoteStream-Subscription |
Subscription ID. |
X-NoteStream-Note-Id |
Note ID. |
X-NoteStream-Content-Hash |
SHA-256 hash used to detect unchanged content. |
X-NoteStream-Occurred-At |
ISO timestamp for the created delivery event. |
When a subscription has a secret, deliveries also include:
X-NoteStream-Signature-256: sha256=<hex hmac>
Verify it by computing HMAC-SHA256 over the raw JSON request body with the subscription secret and comparing the hex digest.
Non-2xx responses and network errors are recorded as delivery failures on the subscription's
lastDeliveryStatus and lastDeliveryError fields.