# 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](/docs/cli) and [MCP server](/docs/mcp) are built on, so anything they can do, you can do directly. All endpoints live under:

```text
https://stashr.me/v1
```

API access is part of [Pro](/docs/plans-and-billing) 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](https://stashr.me/openapi.json) of everything on this page is served at `/openapi.json`.

## Authentication

Every request authenticates with a bearer token:

```sh
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:

```json
{
  "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:

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

| 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 `Retry-After: 60`; back off and retry after that many seconds.

## 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.

## 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` | |
| `mediaMode` | `include`, `exclude` | Default `include` |
| `favorite` | `favorite`, `not-favorite` | |
| `note` | `has-note`, `no-note` | |
| `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.

## 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` |
| Any filter | | See [Filters](#filters) |

```sh
curl "https://stashr.me/v1/bookmarks?platforms=reddit&tags=woodworking&limit=5" \
  -H "Authorization: Bearer $STASHR_API_KEY"
```

Returns `items` (an array of [bookmark objects](#the-bookmark-object)) and `nextCursor`.

### 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`.

```sh
curl https://stashr.me/v1/bookmarks \
  -H "Authorization: Bearer $STASHR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/article" }'
```

```json
{
  "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.

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

### 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.

```sh
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.

```sh
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:

```sh
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"] }'
```

```json
{
  "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](#filters) except `q` |
| `limit` | integer | 1 to 100 |
| `cursor` | string | `nextCursor` from the previous page of the same search |
| `rankMode` | `post`, `image` | Default `post` |

```sh
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", "limit": 10 }'
```

`rankMode: "post"` ranks whole bookmarks. `rankMode: "image"` ranks individual images by their AI vision captions; the page then includes `tileOrder` entries with `bookmarkId`, `ref`, and the matched caption, and 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.

## Collections

Collections are saved filter rules. The `filters` object uses the shared [filter vocabulary](#filters), minus `q`.

### 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 |

```sh
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`, and `filters`; send at least one.

### 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:

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

## 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:

```json
{
  "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`.

## Machine-readable docs

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

## Where to go next

<CardGroup>
  <LinkCard
    slug="cli"
    title="Command-line interface"
    description="The official client for this API, ready for terminals and scripts."
  />
  <LinkCard
    slug="mcp"
    title="Connect your AI (MCP)"
    description="Give MCP-compatible assistants direct access to Stashr."
  />
  <LinkCard
    slug="agent-skill"
    title="Agent skill"
    description="Teach coding agents the right Stashr workflow automatically."
  />
</CardGroup>
