Integrations · API reference

API reference

Search, save, and organize your Stashr library over HTTPS from any language, script, or agent with the Stashr REST API.

The Stashr API is a small JSON API over HTTPS. It is the same API the official CLI and MCP server are built on, so anything they can do, you can do directly. All endpoints live under:

https://stashr.me/v1

API access is part of Pro and included in every free trial. On Hobby, every endpoint except /v1/me returns a payment-required error.

A machine-readable OpenAPI 3.1 description of everything on this page is served at /openapi.json.

Authentication

Every request authenticates with a bearer token:

curl https://stashr.me/v1/me \
  -H "Authorization: Bearer $STASHR_API_KEY"

API keys

Create keys under Settings → API keys in the web app. Keys start with stashr_, are shown once at creation, and carry one of three access levels:

Access levelScopesAllows
ReadreadReading and searching
Writeread, writePlus saving, updating, archiving, and restoring
Fullread, write, destructivePlus collection deletion

Use the smallest level your integration needs: read access for lookup-only automation, write for saving and organizing, and full only when collection deletion is required.

OAuth

Interactive clients can use OAuth instead of a long-lived key. The CLI and MCP connections handle this automatically with stashr login or your AI tool's connection flow, so you rarely need to implement it yourself. If you do, Stashr publishes standard discovery metadata at /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource/v1, and access tokens must be audience-bound to /v1. The supported scopes are read, write, destructive, and offline_access.

Scopes by method

The required scope follows the HTTP method: GET needs read, POST and PATCH need write, and DELETE needs destructive. A credential without the required scope gets a 403 forbidden response.

Requests and responses

Request bodies are JSON and should be sent with Content-Type: application/json. Every successful response wraps its payload in a data envelope:

{
  "data": {
    "items": [],
    "nextCursor": null
  }
}

Every response also carries an X-Request-Id header. Send your own X-Request-Id request header (up to 64 letters, digits, _, or -) to correlate requests with your logs, and include the id when contacting support about a failed call.

Errors

Failures return a consistent envelope with a stable machine-readable code:

{
  "error": {
    "code": "validation_error",
    "message": "Request validation failed",
    "details": {
      "issues": [
        {
          "code": "too_big",
          "message": "Too big: expected number to be <=100",
          "path": ["limit"]
        }
      ]
    },
    "retryable": false
  },
  "requestId": "req_x7k2mfqp"
}

Every error carries retryable: true on rate limits, server errors, and network-class failures, false otherwise. Rate-limited responses additionally include retryAfterMs in the body and the matching Retry-After header, so a client can wait exactly as long as needed.

Statuserror.codeMeaning
400bad_request, invalid_jsonMalformed JSON, or a malformed request such as a collection delete without confirm=true
401unauthorizedMissing, invalid, or expired credentials
402payment_requiredPro or an active trial is required
403forbiddenThe credential lacks the required scope
404not_foundThe resource does not exist or belongs to another account
422validation_errorThe request shape is wrong; details.issues lists each offending field
429rate_limitedToo many requests; wait for the Retry-After header
500internal_errorSomething failed on Stashr's side

Rate limits

Requests are limited to 240 per minute per account. A 429 response includes a Retry-After header counting down the current window; back off and retry after that many seconds.

Responses authenticated with OAuth or a session also carry X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers (Reset counts seconds until the window resets, the same unit as Retry-After), so a client can pace itself before ever hitting a 429. API-key requests are limited separately and do not include these headers.

Pagination

List and search endpoints return one page at a time as items plus a nextCursor. Pass nextCursor back as cursor to continue, and stop when it is null:

  • limit accepts 1 to 100 and defaults to 30.
  • Cursors are opaque and specific to the endpoint and mode that returned them. Passing one to a different endpoint or search mode returns a validation error instead of restarting from the first page.
  • Search cursors are additionally bound to the exact query, filters, and state that produced them. Reusing one with different inputs returns a 422 naming the cause instead of silently skipping the new search's first results.

Filters

Bookmark listing, search, and collection rules share one filter vocabulary. On GET /v1/bookmarks, repeat a query parameter for multiple values (?platforms=reddit&platforms=twitter); in JSON bodies, use arrays.

FilterValuesNotes
platformsinstagram, reddit, tiktok, twitter, web, youtubeX is twitter
platformModeinclude, excludeDefault include
contentTypesarticle, comment, image, post, snippet, videoBare or platform-scoped, such as twitter:article
contentTypeModeinclude, excludeDefault include
authorsUsernamesBare (naval) or platform-scoped (twitter:naval)
authorModeinclude, excludeDefault include
tagsTag namesSee GET /v1/tags
tagModeinclude_any, include_all, exclude_any, exclude_allDefault include_any
mediaimage, video, any-media, text-onlytext-only cannot be combined with the other values
mediaModeinclude, excludeDefault include
favoritefavorite, not-favorite
notehas-note, no-note
untaggedbooleantrue matches bookmarks with no tags at all
capturedAfterISO-8601 date or datetimeInclusive lower bound on when the bookmark was saved
capturedBeforeISO-8601 date or datetimeExclusive upper bound, so a month is capturedAfter=2026-06-01&capturedBefore=2026-07-01
qFree text, up to 200 charactersGET /v1/bookmarks only; /v1/search takes query instead

Invalid filter values fail with a 422 that lists the allowed values, and so do unknown filter names — a typo'd parameter is rejected rather than silently ignored, so a filtered request never quietly returns the whole library.

Bookmarks

List bookmarks

GET /v1/bookmarks

Browses bookmarks in reverse capture order.

Query parameterTypeNotes
cursorstringnextCursor from the previous page
limitinteger1 to 100, default 30
stateactive, archived, allDefault active
viewfull, summaryDefault full; see below
Any filterSee Filters
curl "https://stashr.me/v1/bookmarks?platforms=reddit&tags=woodworking&limit=5&view=summary" \
  -H "Authorization: Bearer $STASHR_API_KEY"

Returns items and nextCursor. With the default view=full, items are complete bookmark objects including raw content blocks. With view=summary, items are compact summary hits — bounded text snippets, tag names, and lightweight media refs, with raw content and platform payloads never leaving the server. Prefer summary for agent discovery and pagination; a summary page is typically orders of magnitude smaller.

Save a URL

POST /v1/bookmarks

Fetches a public URL and saves its metadata as a web bookmark. Saving is idempotent: a URL already in your library returns the existing bookmark with a 200 instead of creating a duplicate; a new save returns 201.

curl https://stashr.me/v1/bookmarks \
  -H "Authorization: Bearer $STASHR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/article" }'
{
  "data": {
    "bookmark": { "id": "019abcde-0000-7000-8000-000000000000" },
    "created": true
  }
}

Get a bookmark

GET /v1/bookmarks/{bookmarkId}

Returns one bookmark in full, including its complete content blocks.

curl https://stashr.me/v1/bookmarks/019abcde-0000-7000-8000-000000000000 \
  -H "Authorization: Bearer $STASHR_API_KEY"

Batch-read bookmarks

POST /v1/bookmarks/batch-read

Returns up to 20 bookmarks in full in one round-trip — the batch companion to GET /v1/bookmarks/{bookmarkId} for comparing or summarizing a shortlist. POST is used only because the id list does not fit a query string; this is a pure read and needs only the read scope.

curl https://stashr.me/v1/bookmarks/batch-read \
  -H "Authorization: Bearer $STASHR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "ids": ["019abcde-0000-7000-8000-000000000000", "019abcde-0000-7000-8000-000000000001"] }'

Returns items (full bookmark objects, in the order the ids were requested) and missing (requested ids that were not found). An id that does not exist never fails the ids that do.

Update a bookmark

PATCH /v1/bookmarks/{bookmarkId}

Changes the note, favorite state, or tags. Send at least one field:

Body fieldTypeNotes
isFavoriteboolean
notestring or nullnull clears the note
tagsobjectadd, remove, and set arrays of tag names

Tag patches accept add and remove together, or set alone to replace everything. Each array holds up to 100 names, and names are normalized to lowercase. Tags that do not exist yet are created.

curl -X PATCH https://stashr.me/v1/bookmarks/019abcde-0000-7000-8000-000000000000 \
  -H "Authorization: Bearer $STASHR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "isFavorite": true, "tags": { "add": ["research"], "remove": ["inbox"] } }'

Download an image preview

GET /v1/bookmarks/{bookmarkId}/media?ref={ref}

Returns a bounded JPEG preview (at most 768px on the long edge) of one stored bookmark image, so agents can look at a picture without pulling full-size media. The required ref comes from the bookmark's media[].ref. The response is binary image/jpeg with X-Stashr-Media-Ref, X-Stashr-Media-Width, and X-Stashr-Media-Height headers.

curl "https://stashr.me/v1/bookmarks/019abcde-0000-7000-8000-000000000000/media?ref=media-1" \
  -H "Authorization: Bearer $STASHR_API_KEY" \
  --output preview.jpg

Only images archived in Stashr are available; remote-only images and videos return 404.

Archive and restore

POST /v1/bookmarks/archive and POST /v1/bookmarks/restore

Archiving is a reversible soft delete; restoring undoes it. Both take 1 to 200 bookmark ids and return the ids that actually changed:

curl https://stashr.me/v1/bookmarks/archive \
  -H "Authorization: Bearer $STASHR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "ids": ["019abcde-0000-7000-8000-000000000000"] }'
{
  "data": { "ids": ["019abcde-0000-7000-8000-000000000000"] }
}

Search bookmarks

POST /v1/search

Searches by meaning and text using the same hybrid engine as the web app.

Body fieldTypeNotes
querystringRequired, 1 to 200 characters
filtersobjectAny filters except q; unknown keys are rejected
limitinteger1 to 100
cursorstringnextCursor from the previous page of the same search
rankModepost, imageDefault post
stateactive, archived, allDefault active, so the archive is searchable too
viewfull, summaryDefault full; summary returns compact hits, as on list
curl https://stashr.me/v1/search \
  -H "Authorization: Bearer $STASHR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "query": "warm timber reading nook", "rankMode": "image", "view": "summary", "limit": 10 }'

rankMode: "post" ranks whole bookmarks. rankMode: "image" ranks individual images by their AI vision captions; a full-view page then includes tileOrder entries with bookmarkId, ref, and the matched caption, while a summary page flattens the ranking into one hit per image with a matchedMedia object. A bookmark can appear more than once when several of its images match. Pair it with the image preview endpoint to inspect a result visually.

Ranking covers the top 200 fused candidates — narrow with filters rather than paging deep. When retrieval quality is reduced, the page says so instead of failing silently: searchDegraded (or degraded on summary pages) lists semantic_unavailable when the query embedding failed and only keyword matching ran, or image_rank_unavailable when an image-ranked request fell back to post ranking because no image captions exist yet. Summary pages also report the rankMode that actually ran, with requestedRankMode present when it differs.

Stats

Library stats

GET /v1/stats

Answers count and overview questions in one call, so "how many Reddit videos did I save this year" never requires paging bookmarks. Accepts the same state parameter and filters as bookmark listing:

curl "https://stashr.me/v1/stats?platforms=reddit&capturedAfter=2026-01-01" \
  -H "Authorization: Bearer $STASHR_API_KEY"
{
  "data": {
    "total": 412,
    "byPlatform": { "reddit": 412 },
    "byContentType": { "post": 320, "video": 58, "comment": 34 },
    "byMonth": { "2026-07": 41, "2026-06": 87 },
    "favorites": 18,
    "archived": 0,
    "untagged": 45,
    "withNote": 12
  }
}

byMonth is keyed by capture month (YYYY-MM, UTC), newest first. state defaults to active; archived counts within the selection, so it is only nonzero with state=archived or state=all.

Collections

Collections are saved filter rules. The filters object uses the shared filter vocabulary, minus q, untagged, and the capture-date bounds.

List collections

GET /v1/collections

Returns every collection, unpaginated, sorted pinned-first and then by manual position.

Create a collection

POST /v1/collections

Body fieldTypeNotes
namestringRequired, 1 to 50 characters
colorstringA collection color id from the app; defaults to blue
iconstringA collection icon id from the app; defaults apply
filtersobjectFilter rules; defaults to none
curl https://stashr.me/v1/collections \
  -H "Authorization: Bearer $STASHR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Design research", "filters": { "tags": ["design"], "media": ["image"] } }'

Invalid color or icon values fail with a 422 listing the allowed ids.

Update a collection

PATCH /v1/collections/{collectionId}

Takes any subset of name, color, icon, filters, and pinned; send at least one. pinned: true pins the collection to the top of the sidebar.

Delete a collection

DELETE /v1/collections/{collectionId}?confirm=true

Requires the destructive scope and the literal query parameter confirm=true; without it the request fails with a 400 instead of deleting. Deletion removes the collection, never its bookmarks.

Tags

List tags

GET /v1/tags

Returns every tag, unpaginated, alphabetically, with the number of active bookmarks currently carrying it — so "most-used tags" is one call:

{
  "data": [
    { "id": "019abcde-0000-7000-8000-000000000001", "name": "research", "color": "blue", "count": 42 }
  ]
}

Account

Get the authenticated account

GET /v1/me

Works on every plan, so it is the right endpoint for connection checks (stashr whoami uses it). Returns the account and an access snapshot:

{
  "data": {
    "id": "user_123",
    "name": "Ada",
    "email": "ada@example.com",
    "image": null,
    "access": {
      "hasAppAccess": true,
      "hasPaidPlan": false,
      "hasProAccess": true,
      "trial": { "tagsRemaining": 212, "trialEndsAt": "2026-07-21T00:00:00.000Z" }
    }
  }
}

The bookmark object

FieldTypeNotes
iduuidStable bookmark id
platformstringinstagram, reddit, tiktok, twitter, web, or youtube
platformIdstringThe post's id on its platform
contentTypestringarticle, comment, image, post, snippet, or video
urlstringLink back to the original post
titlestring or nullPresent on titled content such as X articles
authorobject or nullusername, plus optional displayName, avatarUrl, and profileUrl
contentarrayPortable Text blocks holding the full body
mediaarrayAttached media (see below)
tagsarrayid, name, and color per tag
notestring or nullYour private note
isFavoriteboolean
capturedAtdate-timeWhen Stashr saved it
contentCreatedAtdate-time or nullWhen the original post was published
deletedAtdate-time or nullSet while the bookmark is archived

content is an array of Portable Text blocks: paragraph blocks with styled spans, plus Stashr block types for images, embeds, polls, and code. Each media entry carries a stable ref, a type of image or video, the original url, a storedUrl when Stashr archived the file, and optional width, height, duration, and altText.

The summary hit object

What view=summary list and search pages return per item — everything needed to judge relevance without the raw content:

FieldTypeNotes
id, url, platform, contentTypeAs on the bookmark object
titlestring or nullReal title only; never a fabricated fallback
authorstring or nullDisplay name or @username
textstringPlain-text snippet of the content plus note, at most 600 characters
tagsarray of stringsTag names
mediaarrayLightweight refs: ref, type, and bounded altText — usable with the image preview endpoint
mediaCountinteger
favorite, archivedboolean
capturedAt, contentCreatedAtdate-timecontentCreatedAt nullable
matchedMediaobjectImage-ranked search hits only: the ranked image's ref, caption, altText, dimensions, and URL

For the full body of a chosen hit, fetch GET /v1/bookmarks/{id} — the discovery-then-fetch pattern keeps payloads small without hiding what you need to choose well.

Machine-readable docs

  • /openapi.json: the OpenAPI 3.1 description of this API.
  • /llms.txt: an agent-oriented index of these docs.
  • Append .md to any docs URL, such as /docs/api.md, for the raw Markdown version of a page.

Where to go next

AI agent or LLM? Read this page as Markdown or browse the full docs index at /llms.txt.