- Docs
- 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/v1API 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 level | Scopes | Allows |
|---|---|---|
| Read | read | Reading and searching |
| Write | read, write | Plus saving, updating, archiving, and restoring |
| Full | read, write, destructive | Plus 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.
| Status | error.code | Meaning |
|---|---|---|
| 400 | bad_request, invalid_json | Malformed JSON, or a malformed request such as a collection delete without confirm=true |
| 401 | unauthorized | Missing, invalid, or expired credentials |
| 402 | payment_required | Pro or an active trial is required |
| 403 | forbidden | The credential lacks the required scope |
| 404 | not_found | The resource does not exist or belongs to another account |
| 422 | validation_error | The request shape is wrong; details.issues lists each offending field |
| 429 | rate_limited | Too many requests; wait for the Retry-After header |
| 500 | internal_error | Something 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:
limitaccepts 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
422naming 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.
| Filter | Values | Notes |
|---|---|---|
platforms | instagram, reddit, tiktok, twitter, web, youtube | X is twitter |
platformMode | include, exclude | Default include |
contentTypes | article, comment, image, post, snippet, video | Bare or platform-scoped, such as twitter:article |
contentTypeMode | include, exclude | Default include |
authors | Usernames | Bare (naval) or platform-scoped (twitter:naval) |
authorMode | include, exclude | Default include |
tags | Tag names | See GET /v1/tags |
tagMode | include_any, include_all, exclude_any, exclude_all | Default include_any |
media | image, video, any-media, text-only | text-only cannot be combined with the other values |
mediaMode | include, exclude | Default include |
favorite | favorite, not-favorite | |
note | has-note, no-note | |
untagged | boolean | true matches bookmarks with no tags at all |
capturedAfter | ISO-8601 date or datetime | Inclusive lower bound on when the bookmark was saved |
capturedBefore | ISO-8601 date or datetime | Exclusive upper bound, so a month is capturedAfter=2026-06-01&capturedBefore=2026-07-01 |
q | Free text, up to 200 characters | GET /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 parameter | Type | Notes |
|---|---|---|
cursor | string | nextCursor from the previous page |
limit | integer | 1 to 100, default 30 |
state | active, archived, all | Default active |
view | full, summary | Default full; see below |
| Any filter | See 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 field | Type | Notes |
|---|---|---|
isFavorite | boolean | |
note | string or null | null clears the note |
tags | object | add, 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.jpgOnly 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
Search bookmarks
POST /v1/search
Searches by meaning and text using the same hybrid engine as the web app.
| Body field | Type | Notes |
|---|---|---|
query | string | Required, 1 to 200 characters |
filters | object | Any filters except q; unknown keys are rejected |
limit | integer | 1 to 100 |
cursor | string | nextCursor from the previous page of the same search |
rankMode | post, image | Default post |
state | active, archived, all | Default active, so the archive is searchable too |
view | full, summary | Default 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 field | Type | Notes |
|---|---|---|
name | string | Required, 1 to 50 characters |
color | string | A collection color id from the app; defaults to blue |
icon | string | A collection icon id from the app; defaults apply |
filters | object | Filter 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
| Field | Type | Notes |
|---|---|---|
id | uuid | Stable bookmark id |
platform | string | instagram, reddit, tiktok, twitter, web, or youtube |
platformId | string | The post's id on its platform |
contentType | string | article, comment, image, post, snippet, or video |
url | string | Link back to the original post |
title | string or null | Present on titled content such as X articles |
author | object or null | username, plus optional displayName, avatarUrl, and profileUrl |
content | array | Portable Text blocks holding the full body |
media | array | Attached media (see below) |
tags | array | id, name, and color per tag |
note | string or null | Your private note |
isFavorite | boolean | |
capturedAt | date-time | When Stashr saved it |
contentCreatedAt | date-time or null | When the original post was published |
deletedAt | date-time or null | Set 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:
| Field | Type | Notes |
|---|---|---|
id, url, platform, contentType | As on the bookmark object | |
title | string or null | Real title only; never a fabricated fallback |
author | string or null | Display name or @username |
text | string | Plain-text snippet of the content plus note, at most 600 characters |
tags | array of strings | Tag names |
media | array | Lightweight refs: ref, type, and bounded altText — usable with the image preview endpoint |
mediaCount | integer | |
favorite, archived | boolean | |
capturedAt, contentCreatedAt | date-time | contentCreatedAt nullable |
matchedMedia | object | Image-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
.mdto 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.