NoteSurge

API reference

Create/schedule/list/delete Notes and library pieces from outside the app — the same primitives Compose and Library use, over a plain REST API. Generate a key in Settings → API keys. The endpoint list below is generated from the live capability registry, so it can't drift from what the API actually accepts.

Authentication

Every request needs an Authorization header with a bearer key. A key is bound to one connected account, the same one you generated it from.

Authorization: Bearer nsk_xx...

Missing the header → 401. An invalid or revoked key → 403.

Each key has a scope, chosen when you generate it: full (default), draft_only, or read_only. A draft-only key can create drafts ({"draft": true}) but a real scheduling attempt returns 403 -- useful for handing a key to a lower-trust integration without granting it the ability to actually publish. A read-only key can only call GET endpoints -- every write verb is refused with 403, so it can read your library and notes but never mutate anything. Each key's total call count is metered and visible in Settings → API keys.

Base URL

https://<your-notesurge-domain>/api/v1

Rate limits

60 requests per minute, per key, with a higher ceiling for read-only keys. Over the limit gets a 429 with Retry-After, X-RateLimit-Limit, and X-RateLimit-Remaining headers.

Notes

GET /api/v1/notes read

List the account's scheduled/published/draft Substack Notes, most-recent first.

Request fields

  • status · optional — string — Filter to one status. Omit for all.
  • tag · optional — string — Only notes carrying this tag.
  • q · optional — string — Text search over note content and title.
  • limit · optional — number — Max results, default 20.
curl https://<your-notesurge-domain>/api/v1/notes?status=pending \
  -H "Authorization: Bearer ***"

Response

{
  "notes": [
    {
      "id": "n_8f3k2",
      "content": "Hello from the API",
      "imageUrls": [],
      "platforms": [
        "substack"
      ],
      "status": "scheduled",
      "publishAt": "2026-09-01T14:00:00.000Z",
      "publishedAt": null,
      "error": null,
      "createdAt": "2026-08-27T10:00:00.000Z",
      "noteUrl": null,
      "replyToId": null
    }
  ],
  "total": 1,
  "hasMore": false
}

POST /api/v1/notes publish

Create/schedule a Substack Note for real publishing at publishAt (omit publishAt for next-tick via the public API); pass draft:true to save a draft instead. Over MCP this publishes for real (gated behind the account's Allow-publish flag); over Notey/Telegram it lands as a PROPOSAL the human approves from the Queue first.

Request fields

  • content · optional — string — The note text. Optional only when thread is given.
  • publishAt · optional — string — ISO 8601 timestamp to publish at.
  • platforms · optional — array — ['substack','x','threads', …and 29 more]; defaults to ['substack']. Some need a connection set up in Settings first.
  • queue · optional — boolean — true takes the next free slot from the account's queue schedule instead of a publishAt time.
  • tags · optional — array — Tags to put on the note (lowercased, up to 10).
  • planned · optional — boolean — true parks the note on publishAt as planned: it will NOT publish until a person schedules it.
  • draft · optional — boolean — Public API only: true saves a draft instead of scheduling.
  • allowDuplicate · optional — boolean — true schedules even when a near-duplicate of this note already exists among the last 14 days of drafts, scheduled and published notes. Without it you get a 409 naming the existing note.
  • external · optional — boolean — true records a post ANOTHER tool publishes (e.g. Riverside, Typefully) so it shows on the calendar. NoteSurge never publishes it. Needs content, publishAt (past times are fine) and platforms; media and the per-note options are ignored.
  • source · optional — string — With external: true, the tool that posts it, e.g. 'riverside' or 'typefully'. Stored as the tag via:<source>.
  • title · optional — string — Internal title, never published.
  • thread · optional — object — An authored thread: { posts: [{ text, imageUrls? }], numbering?: none|slash|slash-total, finisher?: { text, quoteFirst? } }. When set it replaces content.
  • firstComment · optional — string — Text posted as a reply right after publish (X, Threads, Bluesky, Mastodon, LinkedIn).
  • plug · optional — object — Auto-plug: a self-reply under your own post. { text, mode: threshold|delay, metric: likes|reposts|replies, threshold, windowHours, delayMinutes }.
  • linkedinOptions · optional — object — LinkedIn options: { mentions: [{ name, urn }], repostOf, document: { title }, poll, article: { url, title, description? }, organizationUrns: [urn:li:organization:...] (post as these company pages; each must be turned on in Settings, and company pages must be approved), alsoAsMember: true (also post as the member) }.
  • platformSettings · optional — object — Per-platform post settings keyed by platform: reddit { subreddit }, lemmy { community }, warpcast { channel }, listmonk { lists, templateId }, hashnode { publicationId }, tiktok { privacy, disableDuet, disableStitch, disableComment }, youtube-shorts { title, privacy }, instagram { collaborators }.
curl https://<your-notesurge-domain>/api/v1/notes \
  -H "Authorization: Bearer ***" \
  -H "content-type: application/json" \
  -d '{"content": "Hello from the API", "publishAt": "2026-09-01T14:00:00Z"}'

# Record a post another tool publishes, so the calendar shows it (never posted from here):
curl https://<your-notesurge-domain>/api/v1/notes \
  -H "Authorization: Bearer ***" \
  -H "content-type: application/json" \
  -d '{"external": true, "source": "riverside", "content": "New clip: the 3am edit", "publishAt": "2026-09-01T17:00:00Z", "platforms": ["x", "instagram"]}'

Response

{
  "note": {
    "id": "n_8f3k2",
    "content": "Hello from the API",
    "imageUrls": [],
    "platforms": [
      "substack"
    ],
    "status": "scheduled",
    "publishAt": "2026-09-01T14:00:00.000Z",
    "publishedAt": null,
    "error": null,
    "createdAt": "2026-08-27T10:00:00.000Z",
    "noteUrl": null,
    "replyToId": null
  }
}

DELETE /api/v1/notes/{id} draft

Cancel/delete one of the account's own notes by id.

Request fields

  • id · required — string — The note id.

Response

{
  "ok": true
}

GET /api/v1/notes/{id} read

Fetch one note by id (the account's own).

Request fields

  • id · required — string — The note id.

Response

{
  "note": {
    "id": "n_8f3k2",
    "content": "Hello from the API",
    "imageUrls": [],
    "platforms": [
      "substack"
    ],
    "status": "scheduled",
    "publishAt": "2026-09-01T14:00:00.000Z",
    "publishedAt": null,
    "error": null,
    "createdAt": "2026-08-27T10:00:00.000Z",
    "noteUrl": null,
    "replyToId": null
  }
}

PATCH /api/v1/notes/{id} draft

Edit one of the account's own notes: content, thread, tags, pin, schedule time and status moves (draft, planned, pending, canceled). Send only the fields to change. Moving a note to 'pending' schedules it and needs a full-scope key.

Request fields

  • id · required — string — The note id.
  • content · optional — string — New note text.
  • publishAt · optional — string — ISO 8601 timestamp.
  • queue · optional — boolean — true takes the next free slot of the account's queue schedule.
  • status · optional — string — Move the note: 'planned' keeps a date without ever auto-publishing, 'draft' unschedules it, 'canceled' cancels it. 'pending' schedules it and is REST-only: agents use schedule_note.
  • tags · optional — array — Replaces the note tags.
  • pinned · optional — boolean — Pin or unpin the note (pinned drafts float to the top).
  • imageUrls · optional — array — Replaces the attached image URLs.
  • title · optional — string — Internal title, never published.
  • thread · optional — object — An authored thread: { posts: [{ text, imageUrls? }], numbering?: none|slash|slash-total, finisher?: { text, quoteFirst? } }. When set it replaces content.
  • firstComment · optional — string — Text posted as a reply right after publish (X, Threads, Bluesky, Mastodon, LinkedIn).
  • plug · optional — object — Auto-plug: a self-reply under your own post. { text, mode: threshold|delay, metric: likes|reposts|replies, threshold, windowHours, delayMinutes }.
  • linkedinOptions · optional — object — LinkedIn options: { mentions: [{ name, urn }], repostOf, document: { title }, poll, article: { url, title, description? }, organizationUrns: [urn:li:organization:...] (post as these company pages; each must be turned on in Settings, and company pages must be approved), alsoAsMember: true (also post as the member) }.
  • platformSettings · optional — object — Per-platform post settings keyed by platform: reddit { subreddit }, lemmy { community }, warpcast { channel }, listmonk { lists, templateId }, hashnode { publicationId }, tiktok { privacy, disableDuet, disableStitch, disableComment }, youtube-shorts { title, privacy }, instagram { collaborators }.
curl -X PATCH https://<your-notesurge-domain>/api/v1/notes/n_8f3k2 \
  -H "Authorization: Bearer ***" \
  -H "content-type: application/json" \
  -d '{"tags": ["launch"], "pinned": true, "status": "planned", "publishAt": "2026-11-06T15:00:00Z"}'

Response

{
  "note": {
    "id": "n_8f3k2",
    "content": "Hello from the API",
    "imageUrls": [],
    "audioUrls": [],
    "audioDuration": null,
    "platforms": [
      "substack"
    ],
    "status": "planned",
    "publishAt": "2026-11-06T15:00:00.000Z",
    "publishedAt": null,
    "error": null,
    "createdAt": "2026-11-04T10:00:00.000Z",
    "noteUrl": null,
    "replyToId": null,
    "tags": [
      "launch"
    ],
    "pinned": true,
    "title": null,
    "thread": null,
    "firstComment": null,
    "plug": null,
    "linkedinOptions": null,
    "platformSettings": null
  }
}

POST /api/v1/notes/{id}/approve draft

The human's per-item approval switch for agent-proposed notes (T1.2 N2): approve moves a proposed note into the live publish queue, decline parks it back in drafts. Human-only -- agents may never call this.

Request fields

  • id · required — string — The proposed note id (from list_notes / the Queue tab).
  • decision · required — string — Approve = move into the live queue as scheduled; decline = park back in drafts.

Response

{
  "id": "n_8f3k2",
  "status": "queued"
}

GET /api/v1/notes/{id}/stats read

Pull measured engagement back for one published note -- Substack restacks/reactions/replies, Bluesky likes/reposts/replies, X likes/reposts/impressions, whichever platform(s) reported stats. Read-only.

Request fields

  • id · required — string — The note id (from list_notes).
curl https://<your-notesurge-domain>/api/v1/notes/n_8f3k2/stats \
  -H "Authorization: Bearer ***"

Response

{
  "id": "n_8f3k2",
  "status": "published",
  "stats": {
    "substack": {
      "restacks": 7,
      "reactionsTotal": 12,
      "repliesCount": 2,
      "fetchedAt": "2026-11-06T10:00:00.000Z"
    },
    "bluesky": {
      "likes": 21,
      "reposts": 4,
      "replies": 3,
      "fetchedAt": "2026-11-06T10:00:00.000Z"
    }
  }
}

Drafts

GET /api/v1/drafts read

List the account's drafts (and optionally planned notes), pinned first, with text search and tag filter.

Request fields

  • q · optional — string — Text search over draft content and title.
  • tag · optional — string — Only drafts carrying this tag.
  • includePlanned · optional — boolean — true also returns planned notes.
  • limit · optional — number — Max results, default 50.
  • offset · optional — number — Pagination offset.
curl "https://<your-notesurge-domain>/api/v1/drafts?q=launch&includePlanned=true" \
  -H "Authorization: Bearer ***"

Response

{
  "notes": [
    {
      "id": "n_8f3k2",
      "content": "Hello from the API",
      "imageUrls": [],
      "audioUrls": [],
      "audioDuration": null,
      "platforms": [
        "substack"
      ],
      "status": "planned",
      "publishAt": "2026-11-06T15:00:00.000Z",
      "publishedAt": null,
      "error": null,
      "createdAt": "2026-11-04T10:00:00.000Z",
      "noteUrl": null,
      "replyToId": null,
      "tags": [
        "launch"
      ],
      "pinned": true,
      "title": null,
      "thread": null,
      "firstComment": null,
      "plug": null,
      "linkedinOptions": null,
      "platformSettings": null
    }
  ],
  "total": 1,
  "hasMore": false
}

POST /api/v1/drafts draft

Save a draft that never auto-publishes. Works with draft-only keys. Takes the same fields as notes.schedule except the scheduling ones.

Request fields

  • content · optional — string — The draft text. Optional only when thread is given.
  • platforms · optional — array — Intended platforms, defaults to ['substack'].
  • imageUrls · optional — array — Image URLs to attach.
  • tags · optional — array — Tags to put on the draft.
  • title · optional — string — Internal title, never published.
  • thread · optional — object — An authored thread: { posts: [{ text, imageUrls? }], numbering?: none|slash|slash-total, finisher?: { text, quoteFirst? } }. When set it replaces content.
  • firstComment · optional — string — Text posted as a reply right after publish (X, Threads, Bluesky, Mastodon, LinkedIn).
  • plug · optional — object — Auto-plug: a self-reply under your own post. { text, mode: threshold|delay, metric: likes|reposts|replies, threshold, windowHours, delayMinutes }.
  • linkedinOptions · optional — object — LinkedIn options: { mentions: [{ name, urn }], repostOf, document: { title }, poll, article: { url, title, description? }, organizationUrns: [urn:li:organization:...] (post as these company pages; each must be turned on in Settings, and company pages must be approved), alsoAsMember: true (also post as the member) }.
  • platformSettings · optional — object — Per-platform post settings keyed by platform: reddit { subreddit }, lemmy { community }, warpcast { channel }, listmonk { lists, templateId }, hashnode { publicationId }, tiktok { privacy, disableDuet, disableStitch, disableComment }, youtube-shorts { title, privacy }, instagram { collaborators }.
curl https://<your-notesurge-domain>/api/v1/drafts \
  -H "Authorization: Bearer ***" \
  -H "content-type: application/json" \
  -d '{"thread": {"posts": [{"text": "First post"}, {"text": "Second post"}], "numbering": "slash"}, "platforms": ["x"], "tags": ["launch"]}'

Response

{
  "note": {
    "id": "n_8f3k2",
    "content": "Hello from the API",
    "imageUrls": [],
    "audioUrls": [],
    "audioDuration": null,
    "platforms": [
      "x"
    ],
    "status": "draft",
    "publishAt": "2026-11-06T15:00:00.000Z",
    "publishedAt": null,
    "error": null,
    "createdAt": "2026-11-04T10:00:00.000Z",
    "noteUrl": null,
    "replyToId": null,
    "tags": [
      "launch"
    ],
    "pinned": true,
    "title": null,
    "thread": {
      "posts": [
        {
          "text": "First post"
        },
        {
          "text": "Second post"
        }
      ],
      "numbering": "slash"
    },
    "firstComment": null,
    "plug": null,
    "linkedinOptions": null,
    "platformSettings": null
  }
}

Comments

GET /api/v1/notes/{id}/comments read

The comment thread on one of the account's own notes, oldest first, with whether it is resolved.

Request fields

  • id · required — string — The note id.
curl https://<your-notesurge-domain>/api/v1/notes/n_8f3k2/comments \
  -H "Authorization: Bearer ***"

Response

{
  "comments": [
    {
      "id": "c_71k",
      "authorName": "Sam",
      "body": "Can we soften the opening line?",
      "createdAt": "2026-11-04T09:00:00.000Z",
      "resolvedAt": null,
      "mentionedUserIds": []
    }
  ],
  "members": [
    {
      "userId": "u_1",
      "name": "Sam",
      "handle": "sam"
    }
  ]
}

PATCH /api/v1/notes/{id}/comments draft

Resolve or reopen a note's comment thread.

Request fields

  • id · required — string — The note id.
  • resolved · required — boolean — true resolves the thread, false reopens it.
curl -X PATCH https://<your-notesurge-domain>/api/v1/notes/n_8f3k2/comments \
  -H "Authorization: Bearer ***" \
  -H "content-type: application/json" \
  -d '{"resolved": true}'

Response

{
  "ok": true
}

POST /api/v1/notes/{id}/comments draft

Add a comment to a note. @handles are resolved against workspace members. The comment is attributed to the person who issued the key.

Request fields

  • id · required — string — The note id.
  • body · required — string — The comment text (up to 4000 characters).
curl https://<your-notesurge-domain>/api/v1/notes/n_8f3k2/comments \
  -H "Authorization: Bearer ***" \
  -H "content-type: application/json" \
  -d '{"body": "Can we soften the opening line?"}'

Response

{
  "comment": {
    "id": "c_71k",
    "authorName": "Sam",
    "body": "Can we soften the opening line?",
    "createdAt": "2026-11-04T09:00:00.000Z",
    "resolvedAt": null,
    "mentionedUserIds": []
  }
}

Tags

GET /api/v1/tags read

Every distinct tag in use on the account's notes, with how many notes carry it.

curl https://<your-notesurge-domain>/api/v1/tags \
  -H "Authorization: Bearer ***"

Response

{
  "tags": [
    {
      "tag": "launch",
      "count": 4
    },
    {
      "tag": "thread",
      "count": 2
    }
  ]
}

Queue

GET /api/v1/queue/best-slot read

The next free posting slot for this account: its saved daily slots (or the defaults), skipping times already taken by a pending or retrying note. Read-only.

curl https://<your-notesurge-domain>/api/v1/queue/best-slot \
  -H "Authorization: Bearer ***"

Response

{
  "next": "2026-11-06T12:30:00.000Z"
}

GET /api/v1/queue/schedule read

The account's queue schedule: the daily slot times and the next free slot they resolve to.

curl https://<your-notesurge-domain>/api/v1/queue/schedule \
  -H "Authorization: Bearer ***"

Response

{
  "slots": [
    "09:00",
    "12:30",
    "17:00"
  ],
  "custom": true,
  "next": "2026-11-06T09:00:00.000Z"
}

PUT /api/v1/queue/schedule draft

Replace the account's queue schedule. Send slots as HH:MM times (1 to 24), or null to reset to the defaults.

Request fields

  • slots · required — array — HH:MM times, deduped and sorted on save. Send null instead of a list to reset to the defaults.
curl -X PUT https://<your-notesurge-domain>/api/v1/queue/schedule \
  -H "Authorization: Bearer ***" \
  -H "content-type: application/json" \
  -d '{"slots": ["09:00", "12:30", "17:00"]}'

Response

{
  "slots": [
    "09:00",
    "12:30",
    "17:00"
  ],
  "custom": true
}

Media

POST /api/v1/media draft

Upload one image or video to attach to a note. Send { image } or { video } as an http(s) URL or data URI. Images are ready at once; a video returns status 'processing' and its id, and GET /v1/media/{id} reports when it is ready. Attach the returned url in a note's imageUrls (image) or videoUrl (video).

Request fields

  • image · optional — string — An http(s) URL or data URI of an image (up to 8 MB).
  • video · optional — string — An http(s) URL or data URI of an mp4 video (up to 100 MB).
curl https://<your-notesurge-domain>/api/v1/media \
  -H "Authorization: Bearer ***" \
  -H "content-type: application/json" \
  -d '{"image": "https://example.com/photo.jpg"}'

Response

{
  "id": null,
  "kind": "image",
  "status": "ready",
  "url": "https://substackcdn.com/image/fetch/example.jpg"
}

POST /api/v1/media/from-url draft

Upload one image or video FROM a URL to attach to a note: the file is downloaded through the SSRF guard and hosted on the account. An image is ready at once; a video returns status 'processing' and its id (GET /v1/media/{id} reports when it is ready). Attach the returned url in a note's imageUrls (image) or videoUrl (video).

Request fields

  • image · optional — string — An http(s) URL of an image (up to 8 MB).
  • video · optional — string — An http(s) URL of an mp4 video (up to 100 MB).
curl https://<your-notesurge-domain>/api/v1/media/from-url \
  -H "Authorization: Bearer ***" \
  -H "content-type: application/json" \
  -d '{"image": "https://example.com/photo.jpg"}'

Response

{
  "id": null,
  "kind": "image",
  "status": "ready",
  "url": "https://substackcdn.com/image/fetch/example.jpg"
}

GET /api/v1/media/{id} read

Processing status of an uploaded video: 'processing', 'ready' or 'failed'. The id comes from media.upload.

Request fields

  • id · required — string — The media id from media.upload.
curl https://<your-notesurge-domain>/api/v1/media/12345 \
  -H "Authorization: Bearer ***"

Response

{
  "id": "media-upload:12345",
  "kind": "video",
  "status": "ready",
  "url": "media-upload:12345"
}

Library

GET /api/v1/library read

List Library pieces (source ideas and repurposed/adapted drafts), newest first, optionally filtered by text query or tag.

Request fields

  • q · optional — string — Text search across title/body/tags.
  • tag · optional — string — Filter to one tag.
  • limit · optional — number — Max results.
  • offset · optional — number — Pagination offset.
  • id · optional — string — Exact-id fetch of ONE piece (org-scoped): returns that piece's full record as an OBJECT, not a list. Unknown or foreign ids are a clean not-found error.

Response

{
  "pieces": [
    {
      "id": "p_2m4x9",
      "title": "Idea worth repurposing later",
      "body": "An idea worth repurposing later.",
      "status": "active",
      "tags": [
        "ai"
      ],
      "isEvergreen": false,
      "performanceScore": null,
      "createdAt": "2026-08-27T10:00:00.000Z"
    }
  ]
}

POST /api/v1/library draft

Add a piece to the Library from raw text (title/tags/evergreen flag optional). Never publishes anything.

Request fields

  • text · required — string — The piece body text.
  • title · optional — string — Display title.
  • tags · optional — array — Tags for filtering/search.
  • isEvergreen · optional — boolean — Mark as evergreen content.
curl https://<your-notesurge-domain>/api/v1/library \
  -H "Authorization: Bearer ***" \
  -H "content-type: application/json" \
  -d '{"text": "An idea worth repurposing later.", "tags": ["ai"]}'

Response

{
  "piece": {
    "id": "p_2m4x9",
    "title": "Idea worth repurposing later",
    "body": "An idea worth repurposing later.",
    "status": "active",
    "tags": [
      "ai"
    ],
    "isEvergreen": false,
    "performanceScore": null,
    "createdAt": "2026-08-27T10:00:00.000Z"
  }
}

DELETE /api/v1/library/{id} draft

Archive one of the organization's Library pieces by id (reversible, like every archive path in the app).

Request fields

  • id · required — string — The library piece id.

Response

{
  "piece": {
    "id": "p_2m4x9",
    "title": "Idea worth repurposing later",
    "body": "An idea worth repurposing later.",
    "status": "archived",
    "tags": [
      "ai"
    ],
    "isEvergreen": false,
    "performanceScore": null,
    "createdAt": "2026-08-27T10:00:00.000Z"
  }
}

POST /api/v1/library/{id}/carousel draft

Break a text Library piece into a platform-shaped image carousel: one AI turn writes grounded slide text, the deterministic satori renderer rasterizes each slide to a PNG, bytes land as real media_assets + a per-template marker. Draft-tier; nothing publishes. Idempotent per template.

Request fields

  • pieceId · required — string — The id of the Library piece to render a carousel for (see library.list).
  • template · optional — string — Target platform shape. Omit to use the account's saved preference.

Response

{
  "templateId": "instagram",
  "slides": [
    {
      "rank": 1,
      "assetId": "ma_4r8l3",
      "url": "https://example.blob.vercel-storage.com/media/org_1/abc-slide-1.png"
    },
    {
      "rank": 2,
      "assetId": "ma_5s9m4",
      "url": "https://example.blob.vercel-storage.com/media/org_1/abc-slide-2.png"
    }
  ],
  "renderedAt": "2026-09-19T12:00:00.000Z",
  "answeredBy": "gpt-4o",
  "scopeNote": "Image assets attached to the piece; nothing is posted anywhere automatically."
}

POST /api/v1/library/{id}/quote-card draft

Break a text Library piece into a branded quote-card image: one AI turn writes grounded copy, the deterministic satori renderer composes the PNG, bytes land as a real media_asset. Draft-tier; nothing is posted anywhere automatically. Idempotent per piece (re-running refuses already).

Request fields

  • pieceId · required — string — The id of the Library piece to render a quote card for (see library.list).

Response

{
  "url": "https://example.blob.vercel-storage.com/media/org_1/abc-quote-card.png",
  "assetId": "ma_3q7k2",
  "renderedAt": "2026-09-19T12:00:00.000Z",
  "answeredBy": "gpt-4o",
  "subhead": "Shipping fast wins",
  "body": "Small teams ship faster. Here is why.",
  "templateId": "surge",
  "scopeNote": "An image asset attached to the piece; nothing is posted anywhere automatically."
}

POST /api/v1/library/{id}/render-clips draft

Render one clip candidate from a transcribed, clip-suggested upload to a short-form shape (vertical 9:16 | square 1:1) via the external FFmpeg service, storing the real rendered video as a media asset + marker. Draft-tier; the human reviews before anything posts. Idempotent per target (re-render wipes the old marker first).

Request fields

  • pieceId · required — string — The id of the Library piece to render a clip from (must be transcribed + clip-suggested).
  • target · optional — string — Output shape. Defaults to 'vertical' (9:16).
  • clipRank · optional — number — Which ranked clip candidate to render (1-based; default is the top-ranked).
  • reRender · optional — boolean — When true, wipes the existing marker + assets for this target before re-rendering (bypasses the idempotency refusal).

Response

{
  "targetId": "vertical",
  "clip": {
    "start": "00:01.20",
    "end": "00:18.00"
  },
  "assetId": "ma_6t0n5",
  "assetUrl": "https://example.blob.vercel-storage.com/media/org_1/abc-clip.mp4",
  "renderedAt": "2026-09-19T12:00:00.000Z",
  "answeredBy": "claude-3-5-sonnet",
  "trim": null,
  "captionStyle": null,
  "captionSkipReason": null,
  "captionError": null,
  "thumbnailError": null,
  "previewUrls": null,
  "previewError": null,
  "scopeNote": "A rendered video asset attached to the piece; nothing is posted anywhere automatically."
}

POST /api/v1/library/{id}/transcribe draft

Transcribe a Library piece's audio/video: Whisper over the stored bytes, then summary + chapter markers written back onto the piece and its source. Draft-tier. Idempotent (re-running refuses already).

Request fields

  • pieceId · required — string — The id of the Library piece to transcribe (must be an audio/video upload with a stored asset).

Response

{
  "summary": "A conversation about shipping fast with small teams.",
  "chapters": [
    {
      "start": "00:00",
      "title": "Welcome"
    },
    {
      "start": "03:12",
      "title": "Shipping fast"
    }
  ],
  "body": "Welcome to the show. Today we talk about shipping fast and why small teams win.",
  "answeredBy": "whisper-1 + gpt-4o"
}

Analytics

GET /api/v1/analytics/best-times read

Suggested publish times per platform from THIS account's own measured engagement history -- not generic best-time tables. Read-only; a suggestion to consider when scheduling, never applied automatically.

Request fields

  • platforms · optional — array — ['substack','bluesky','x', …and 1 more]; omit for all supported ones.
curl https://<your-notesurge-domain>/api/v1/analytics/best-times \
  -H "Authorization: Bearer ***"

Response

{
  "suggestions": [
    {
      "platform": "x",
      "suggestedAtUtc": "2026-09-02T14:00:00.000Z",
      "hourUtc": 14,
      "scoreInHour": 41,
      "samples": 18
    }
  ],
  "coverage": [
    {
      "platform": "substack",
      "samples": 4,
      "sufficient": false
    },
    {
      "platform": "x",
      "samples": 18,
      "sufficient": true
    }
  ]
}

GET /api/v1/analytics/query read

Run one read-only SQL SELECT (DuckDB dialect) over THIS account's own posting data: posts, notes, follower_snapshots, subscriber_snapshots and link_clicks. The database is built fresh for each call from this account's rows only, so nothing else (other accounts, credentials, tokens) exists to be read. SELECT only; writes, DDL, COPY, ATTACH, file and network access are refused. Capped in time and rows. The text of imported LinkedIn and X archive posts is withheld (NULL) on this door because imported text is untrusted; counts, scores and dates are there.

Request fields

  • sql · required — string — One SELECT statement. See the tool description for the tables.
  • maxRows · optional — number — Rows to return at most, default 500, ceiling 1000.
curl -G https://<your-notesurge-domain>/api/v1/analytics/query \
  --data-urlencode "sql=SELECT platform, count(*) AS posts, round(avg(score), 1) AS avg_score FROM posts GROUP BY platform ORDER BY avg_score DESC" \
  -H "Authorization: Bearer ***"

Response

{
  "columns": [
    {
      "name": "platform",
      "type": "VARCHAR"
    },
    {
      "name": "posts",
      "type": "BIGINT"
    },
    {
      "name": "avg_score",
      "type": "DOUBLE"
    }
  ],
  "rows": [
    {
      "platform": "bluesky",
      "posts": 13,
      "avg_score": 15.9
    },
    {
      "platform": "x",
      "posts": 13,
      "avg_score": 15.8
    }
  ],
  "rowCount": 2,
  "truncated": false,
  "durationMs": 3
}

GET /api/v1/analytics/report read

An auto-generated posting performance report for THIS account: totals, per-platform averages, weekly trend, top posts, best posting hours and weekdays, follower and subscriber movement and short-link clicks, computed with DuckDB from the account's own measured data. Returns Markdown plus the structured numbers. Read-only.

Request fields

  • windowDays · optional — number — Look-back window in days, default 90.
curl "https://<your-notesurge-domain>/api/v1/analytics/report?windowDays=90" \
  -H "Authorization: Bearer ***"

Response

{
  "summary": [
    "40 measured posts across 3 platforms, last 90 days (from 2026-07-02)."
  ],
  "markdown": "# Posting performance report\n\n## Summary\n...",
  "data": {
    "windowDays": 90,
    "totals": {
      "posts": 40,
      "platforms": 3,
      "totalScore": 646,
      "avgScore": 16.2
    },
    "byPlatform": [],
    "topPosts": []
  }
}

GET /api/v1/analytics/top-posts read

This account's recent top posts per measured platform (Substack Notes / Bluesky / X), ranked by real engagement our own stats pass recorded -- not estimates. Read-only evidence for 'what worked', feeding repurposing and scheduling advice.

Request fields

  • platforms · optional — array — ['substack','bluesky','x', …and 1 more]; omit for all supported ones.
  • limit · optional — number — Max posts per platform, default 5.
  • windowDays · optional — number — Recency window in days, default 30.
curl "https://<your-notesurge-domain>/api/v1/analytics/top-posts?platforms=x,bluesky&limit=3&windowDays=30" \
  -H "Authorization: Bearer ***"

Response

{
  "platforms": [
    {
      "platform": "x",
      "posts": [
        {
          "id": "n_8f3k2",
          "excerpt": "Hello from the API",
          "publishedAt": "2026-08-20T14:00:00.000Z",
          "metrics": {
            "likes": 12,
            "reposts": 3
          }
        }
      ],
      "measuredCount": 9,
      "lastMeasuredAt": "2026-08-27T10:00:00.000Z"
    }
  ],
  "note": ""
}

SDK

A typed, zero-dependency TypeScript client wraps the notes, library and analytics endpoints below.

import { NoteSurge } from '@notesurge/sdk'

const ns = new NoteSurge({ apiKey: 'nsk_...', baseUrl: 'https://<your-notesurge-domain>' })
await ns.schedule.at('Shipping on Friday.', new Date('2026-11-06T15:00:00Z'))

Agent tools

GET /api/v1/cronjobs read

List this workspace's scheduled agent tasks (standing tasks) -- their names, kinds, schedules, next fire time, and honest last-run status. Read-only: returns what's already scheduled, never starts or changes anything.

curl https://<your-notesurge-domain>/api/v1/cronjobs \
  -H "Authorization: Bearer ***"

Response

{
  "tasks": [
    {
      "id": "t_a1b2c3",
      "name": "Monday repurposes",
      "kind": "repurpose_digest",
      "kindLabel": "Draft repurposes",
      "produces": "Draft notes (review-ready; your approval is required to go out)",
      "costEstimate": "0 AI tokens — deterministic from your measured pieces",
      "schedule": {
        "cadence": "weekly",
        "hourUtc": 9,
        "dayOfWeekUtc": 1
      },
      "scheduleLabel": "Monday at 09:00 UTC",
      "enabled": true,
      "nextRunAt": "2026-09-08T09:00:00.000Z",
      "lastRunAt": "2026-09-01T09:00:00.000Z",
      "status": "Last run succeeded",
      "failed": false,
      "createdAt": "2026-08-27T10:00:00.000Z"
    }
  ],
  "kinds": [
    "repurpose_digest",
    "evergreen_archive_dive"
  ]
}

POST /api/v1/cronjobs draft

Schedule a new standing agent task for this workspace. Provide a name, a task kind (the kind determines what the run drafts for review), and a schedule (daily or weekly at a fixed UTC hour). The run does NOT publish automatically -- it drafts notes for your review first, the same consent line A3 draws. Owner/admin only.

Request fields

  • name · required — string — A human label for the task (e.g. "Monday repurposes").
  • kind · required — string — The task kind -- a stable id from the kinds registry (call list_cronjobs to see valid kinds).
  • schedule · required — object — The recurring cadence. Daily fires every day at hourUtc; weekly fires on dayOfWeekUtc at hourUtc.
  • enabled · optional — boolean — Whether the task is active. Defaults to true.
  • taskPrompt · optional — string — The per-run prompt for the standalone_task kind (required when kind=standalone_task; must be omitted for other kinds).
curl https://<your-notesurge-domain>/api/v1/cronjobs \
  -H "Authorization: Bearer ***" \
  -H "content-type: application/json" \
  -d '{"name": "Monday repurposes", "kind": "repurpose_digest", "schedule": {"cadence": "weekly", "hourUtc": 9, "dayOfWeekUtc": 1}}'

Response

{
  "task": {
    "id": "t_a1b2c3",
    "name": "Monday repurposes",
    "kind": "repurpose_digest",
    "kindLabel": "Draft repurposes",
    "produces": "Draft notes (review-ready; your approval is required to go out)",
    "costEstimate": "0 AI tokens — deterministic from your measured pieces",
    "schedule": {
      "cadence": "weekly",
      "hourUtc": 9,
      "dayOfWeekUtc": 1
    },
    "scheduleLabel": "Monday at 09:00 UTC",
    "enabled": true,
    "nextRunAt": "2026-09-08T09:00:00.000Z",
    "lastRunAt": null,
    "status": "Never run",
    "failed": false,
    "createdAt": "2026-08-27T10:00:00.000Z"
  }
}

DELETE /api/v1/cronjobs/{id} draft

Remove a scheduled agent task for this workspace. The task stops firing; nothing is published as part of removal. Owner/admin only.

Request fields

  • id · required — string — The standing task id to delete (from list_cronjobs).

Response

{
  "ok": true
}

MCP resources and prompts

The MCP server at /api/mcp also serves read-only resources and prompts. A client such as Claude Desktop can attach a resource without making a tool call. Every resource is scoped to the account your MCP token belongs to, and needs the read scope.

Text written by someone else, such as Library pieces and agent-proposed notes, comes back inside untrusted-content markers. Treat it as data, never as instructions. Reading it also counts as an untrusted read: for the next 30 minutes, publishing tools on the same token are held for your approval, the same as after a web read.

Prompts list the built-in skills and the starter pack. Fetch one with its name (starter skills are named starter-<name>) and the client gets the plan, ready to show you before anything runs.

Webhooks

Register a URL in Settings → Webhooks to get a POST whenever a note is scheduled, published, or fails — the automation glue for Zapier, n8n, Make, or your own script.

Payload

{
  "event": "note.published",
  "timestamp": "2026-08-14T12:00:00.000Z",
  "data": {
    "note": { "id": "...", "content": "...", "status": "published", "noteUrl": "...", ... }
  }
}

event is one of note.scheduled, note.proposed, note.published, note.failed, library.created, or library.archived. The note object (for the note.* events) or piece object (for the library.* events) is the same shape the corresponding endpoint above returns.

Verifying a delivery

Every request carries a signature over the raw request body, HMAC-SHA256 keyed with the webhook's own signing secret (shown when you add the webhook, and viewable again any time from Settings), hex-encoded:

X-NoteSurge-Event: note.published
X-NoteSurge-Signature: <hex hmac-sha256 of the raw request body>

Recompute it yourself and compare with a timing-safe equality check (Node.js example):

const crypto = require('crypto')

function isValid(rawBody, signatureHeader, secret) {
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex')
  const expectedBuf = Buffer.from(expected)
  const givenBuf = Buffer.from(signatureHeader || '')
  // timingSafeEqual throws on a length mismatch instead of returning false --
  // a missing/truncated/malformed header must not crash your receiver.
  if (expectedBuf.length !== givenBuf.length) return false
  return crypto.timingSafeEqual(expectedBuf, givenBuf)
}

The scheme above is small enough to reimplement in whatever language your receiving endpoint runs. A delivery that doesn't get a 2xx response is recorded (not retried) — check the webhook's last-delivery status in Settings if events seem to be going missing.

Notes on scope

One scope per key — full, draft_only, or read_only. Raw REST is enough for most automation (Zapier, scripts, etc.), or use the TypeScript SDK above. A key is also tied to the person who issued it: if they leave the workspace, the key stops working. Every error response is { error: string }.