Tagstash REST API

Read and edit your bookmark library from a script, or share public bookmarks on your own site.

Base URL: https://tagsta.sh/api. Requests and responses use JSON.

Get started with a personal key

  1. Sign in and open Settings → API Keys.
  2. Optionally name the key, then select Generate API Key. You can have up to 10 active keys.
  3. Copy the full key immediately. It is shown once; Tagstash stores only a SHA-256 hash and a short display hint. If you lose it, revoke it and generate another.

Send Authorization: Bearer <key> on each library request. Keys start with tsk_ followed by 48 lowercase hexadecimal characters. Keep the key in your server or script environment; a key embedded in a public website gives visitors access to your library.

These examples use a POSIX shell. Set the environment variable to your own key, then read your library:

export TAGSTASH_API_KEY='PASTE_YOUR_KEY_HERE'
curl --fail-with-body 'https://tagsta.sh/api/bookmarks' \
  -H "Authorization: Bearer $TAGSTASH_API_KEY"

For nonempty JSON bodies, send Content-Type: application/json. A body must be a JSON object, not an array. Empty bodies are treated as an empty object by routes that parse JSON.

curl --fail-with-body 'https://tagsta.sh/api/bookmarks' \
  -H "Authorization: Bearer $TAGSTASH_API_KEY" \
  -H 'Content-Type: application/json' \
  --data '{"title":"Example reference","url":"https://example.com/","description":"A useful reference","tags":["reference"]}'

Keys reach only the bookmark and tag routes listed below. They cannot manage your account, password, email, billing, keys, account deletion, library reset, admin actions or support. Create and revoke keys in Settings.

Revoked or unknown keys, and keys for suspended, deleting or missing accounts, are refused. Changing your password in Settings, confirming an email change or signing out of all devices keeps keys active. A password reset through the forgot-password email revokes all active keys.

Library endpoints use an origin allowlist for browser CORS. Use a server or command-line client for your integration. A personal key does not enable arbitrary cross-origin browser access; the keyless public profile API below does.

Bookmark fields and writes

Bookmark in this reference means the following JSON object. This is an illustrative response; ids and dates come from your account.

{
  "id": 123,
  "user_id": 7,
  "title": "Example reference",
  "url": "https://example.com/",
  "description": "A useful reference",
  "note": null,
  "favicon_url": "https://www.google.com/s2/favicons?sz=64&domain=example.com",
  "created_at": "2026-10-25 12:00:00",
  "updated_at": "2026-10-25 12:00:00",
  "is_favorite": 0,
  "is_private": 0,
  "deleted_at": null,
  "tags": [
    {
      "id": 4,
      "name": "reference"
    }
  ]
}

Ids are numbers. Bookmark flags are integers (0 or 1). Description, note and favicon may be null. Database timestamps are UTC, usually YYYY-MM-DD HH:MM:SS; deleted_at is null for live bookmarks. Trash responses also include expires_at.

Create and update accept title, url, description, note and tags. Title is trimmed. URLs must be HTTP(S); a missing scheme is prefixed with HTTPS, and bare roots keep a trailing slash. Title and URL are required for both POST and PUT. Omitting description on PUT clears it; omitting note or tags keeps the existing value.

note is a private Markdown note, up to 10,000 characters. Only you can read it: it is never included in your public profile, its API or the sitemap, even for public bookmarks. It is stored as sent (not trimmed) and returned as raw Markdown; rendering it safely is up to your client. Null or a blank string clears it.

Tags are trimmed, lowercased, de-duplicated single words. Blank names are dropped. Null or an empty array clears tags. Invalid types, spaces inside a tag or limits exceeded reject a normal create/update with 400 before any write. Use the dedicated toggle endpoints for favorites and privacy; these flags are not create/update body fields.

Success shapes below use type notation, such as string, Bookmark[] and ? for an optional field. Actual responses are JSON objects.

Bookmarks

Read your library

GET /api/bookmarks

Request
No body or query parameters. Returns all live bookmarks, including private ones, newest created_at first. No server pagination, search or tag filters.
200 response
{ bookmarks: Bookmark[] }
Behavior and errors
Shared authentication and server errors. The shared errors below also apply.

Read one bookmark

GET /api/bookmarks/:id

Request
id: your live bookmark id. No body.
200 response
{ bookmark: Bookmark }
Behavior and errors
404 if missing, not yours or in the trash. The shared errors below also apply.

Find a saved URL

GET /api/bookmarks/by-url

Request
url: required URL query parameter. Matches the normalized URL or its legacy root form; returns the lowest matching id. Trashed copies do not match.
200 response
{ bookmark: Bookmark }
Behavior and errors
400 without url; 404 if no live match. The shared errors below also apply.

Save a bookmark

POST /api/bookmarks

Request
title and url: required nonblank strings, on both create and update. description: optional string or null; omitted, null or empty clears it. note: optional private Markdown string or null; omitted keeps it on update, null or blank clears it, otherwise stored exactly as sent. tags: optional array of single-word strings or null; omitted keeps tags on update, null or [] clears them. See field rules below.
201 response
{ message: string, bookmark: Bookmark }
Behavior and errors
400 invalid fields; 403 at the free-plan limit; 503 if a note is sent before the notes migration (nothing is saved). A saved URL that is in the trash replaces that trashed copy. The shared errors below also apply.

Edit a bookmark

PUT /api/bookmarks/:id

Request
title and url: required nonblank strings, on both create and update. description: optional string or null; omitted, null or empty clears it. note: optional private Markdown string or null; omitted keeps it on update, null or blank clears it, otherwise stored exactly as sent. tags: optional array of single-word strings or null; omitted keeps tags on update, null or [] clears them. See field rules below.
200 response
{ message: string, bookmark: Bookmark }
Behavior and errors
400 invalid fields; 404 if missing, not yours or trashed; 503 if a note is sent before the notes migration. Send title and url even when only changing tags. The shared errors below also apply.

Move to trash

DELETE /api/bookmarks/:id

Request
No body. Removes the bookmark from the live library.
200 response
{ message: string, trashed: boolean, retentionDays?: number }
Behavior and errors
404 if no live bookmark of yours matches. retentionDays is present when trashed is true. The shared errors below also apply.

Toggle a bookmark favorite

POST /api/bookmarks/:id/favorite

Request
No body. Toggles the current value; repeating the request toggles it again.
200 response
{ message: string, bookmark: { id: number, is_favorite: 0 | 1 } }
Behavior and errors
400 invalid id; 404 if missing, not yours or trashed; 503 if not configured. The shared errors below also apply.

Toggle bookmark privacy

POST /api/bookmarks/:id/private

Request
No body. Toggles whether this bookmark is excluded from your public profile. Repeating toggles it again.
200 response
{ message: string, bookmark: { id: number, is_private: 0 | 1 } }
Behavior and errors
400 invalid id; 404 if missing, not yours or trashed; 503 if not configured. The shared errors below also apply.

To update or trash a bookmark, replace 123 with an id from your library:

curl --fail-with-body -X PUT 'https://tagsta.sh/api/bookmarks/123' \
  -H "Authorization: Bearer $TAGSTASH_API_KEY" \
  -H 'Content-Type: application/json' \
  --data '{"title":"Updated reference","url":"https://example.com/","tags":["reading"]}'

curl --fail-with-body -X DELETE 'https://tagsta.sh/api/bookmarks/123' \
  -H "Authorization: Bearer $TAGSTASH_API_KEY"

Tags

List your tags

GET /api/bookmarks/tags/all

Request
No body. Only tags used on your live bookmarks. Sorted by favorite first, then count descending, then name ascending.
200 response
{ tags: [{ id: number, name: string, count: number, is_favorite: 0 | 1 }] }
Behavior and errors
Shared authentication and server errors. The shared errors below also apply.

Rename a tag

PUT /api/bookmarks/tags/:tagId

Request
name: required nonblank single-word string. Trimmed and lowercased. Renaming onto a tag you already use merges them; your favorite carries over.
200 response
{ message: string, tag: { id: number | null, name: string }, merged: boolean }
Behavior and errors
400 invalid id/name or unchanged name; 404 unless used on your live bookmarks. Changes your trashed bookmarks too; other accounts are unaffected. The shared errors below also apply.

Remove a tag

DELETE /api/bookmarks/tags/:tagId

Request
No body. Unlinks the tag from your live and trashed bookmarks and removes your favorite; keeps the bookmarks.
200 response
{ message: string, tag: { id: number, name: string }, removedFrom: number }
Behavior and errors
400 invalid id; 404 unless used on your live bookmarks. removedFrom counts live bookmarks only. Other accounts are unaffected. The shared errors below also apply.

Merge two tags

POST /api/bookmarks/tags/merge

Request
sourceTagId, targetTagId: required positive integer ids, different from each other. Both tags must be used on your live bookmarks. Moves your source links (including trashed bookmarks) to the target; removes the source favorite without carrying it over.
200 response
{ message: string, tag: { id: number, name: string } }
Behavior and errors
400 invalid or identical ids; 404 if either tag is missing or not used by you. Other accounts are unaffected. The shared errors below also apply.

Toggle a tag favorite

POST /api/bookmarks/tags/:tagId/favorite

Request
No body. Tag must be used on your live bookmarks, or already be your favorite (so a stale favorite can be removed). Response uses a boolean here.
200 response
{ message: string, tag: { id: number, name: string, is_favorite: boolean } }
Behavior and errors
400 invalid id; 404 if not available to you; 503 if not configured. Repeating toggles it again. The shared errors below also apply.

Bulk actions

Move selected bookmarks to trash

POST /api/bookmarks/bulk/delete

Request
ids: required nonempty array of positive safe integers. Maximum 500 entries before de-duplication. Counts in the response use the de-duplicated ids.
200 response
{ message: string, requested: number, deleted: number, skipped: number, trashed: boolean, retentionDays?: number }
Behavior and errors
400 invalid ids. Missing, foreign and already-trashed ids are skipped without revealing which. retentionDays is present when trashed is true. The shared errors below also apply.

Add or remove tags on selected bookmarks

POST /api/bookmarks/bulk/tags

Request
ids: required nonempty array of positive safe integers. Maximum 500 entries before de-duplication. Counts in the response use the de-duplicated ids. add and remove: optional arrays of tag strings (null means empty). At least one must be nonempty; the same name cannot appear in both. Each list uses the tag rules below.
200 response
{ message: string, requested: number, matched: number, skipped: number, overLimit: number, tagsAdded: number, tagsRemoved: number }
Behavior and errors
400 invalid fields. Only your live bookmarks match. Additions that would exceed the tag cap are skipped for that bookmark and counted in overLimit; requested removals still run. The addition cap is checked before removals, so removing tags in this request does not make room for additions. tagsAdded/tagsRemoved count links. matched/overLimit are informational counts read before the guarded write. The shared errors below also apply.

Trash

Read your trash

GET /api/bookmarks/trash

Request
No body. Newest deleted_at first, then id descending. Excludes expired items.
200 response
{ bookmarks: (Bookmark & { expires_at: string })[], retentionDays: number, available: boolean }
Behavior and errors
Before trash is configured: 200 with bookmarks: [] and available: false. The shared errors below also apply.

Restore one bookmark

POST /api/bookmarks/:id/restore

Request
No body. Restores a nonexpired bookmark in your trash, with its tags. Does not change updated_at.
200 response
{ message: string, restored: 1 }
Behavior and errors
400 invalid id; 404 if not in your unexpired trash; 403 if it would exceed the free limit; 503 if trash is unavailable. The shared errors below also apply.

Restore selected bookmarks

POST /api/bookmarks/bulk/restore

Request
ids: required nonempty array of positive safe integers. Maximum 500 entries before de-duplication. Counts in the response use the de-duplicated ids. All restorable bookmarks must fit the plan limit or none are restored.
200 response
{ message: string, requested: number, restored: number, skipped: number }
Behavior and errors
400 invalid ids; 403 if the selection would exceed the free limit; 503 if unavailable. Missing, foreign, live and expired ids are skipped. The shared errors below also apply.

Delete one trashed bookmark forever

DELETE /api/bookmarks/:id/permanent

Request
No body. Permanent and irreversible; only matches your unexpired trash.
200 response
{ message: string }
Behavior and errors
400 invalid id; 404 if not in your unexpired trash; 503 if unavailable. The shared errors below also apply.

Delete selected trashed bookmarks forever

POST /api/bookmarks/bulk/purge

Request
ids: required nonempty array of positive safe integers. Maximum 500 entries before de-duplication. Counts in the response use the de-duplicated ids. Permanent and irreversible.
200 response
{ message: string, requested: number, deleted: number, skipped: number }
Behavior and errors
400 invalid ids; 503 if unavailable. Missing, foreign, live and expired ids are skipped. The shared errors below also apply.

Empty your trash

POST /api/bookmarks/trash/empty

Request
No body. Permanently deletes all of your trashed bookmarks, including expired rows awaiting cleanup. Your live library is kept.
200 response
{ message: string, deleted: number }
Behavior and errors
503 if trash is unavailable. This cannot be undone. The shared errors below also apply.

Delete and restore behavior

Delete moves bookmarks to the trash for 30 days, with their tags. They disappear from live reads, tag counts and public profiles and stop counting toward the free plan. At the retention boundary they are treated as gone, even if physical cleanup has not run yet. Delete forever, purge and empty cannot be undone.

A new save or import of a URL in your trash replaces the trashed copy permanently. Restore preserves the saved fields and tags, subject to any tag edits made while the bookmark was trashed. A bulk restore that would exceed the free limit restores nothing.

On a self-hosted database before the trash migration, delete is permanent and returns trashed: false; the list returns available: false, and restore/purge/empty return 503. Check the response before offering undo.

curl --fail-with-body -X POST 'https://tagsta.sh/api/bookmarks/123/restore' \
  -H "Authorization: Bearer $TAGSTASH_API_KEY"

Metadata and import

Read page metadata

POST /api/bookmarks/meta

Request
url: required string for a public HTTP(S) HTML page. Metadata is not saved automatically. Subject to the shared page-lookup budget.
200 response
{ title: string | null, description: string | null }
Behavior and errors
400 unsafe/invalid URL; 404 no metadata; 422 non-HTML content; 429 budget exhausted; 500 fetch failure; 502 blocked response without metadata or refused redirect; 504 timeout. At least one field is nonempty on success. The shared errors below also apply.

Read a page description

POST /api/bookmarks/meta-description

Request
url: required string. Same public-page restrictions and shared budget as /meta.
200 response
{ description: string }
Behavior and errors
400 unsafe/invalid URL; 404 no description; 422 non-HTML content; 429 budget exhausted; 500 fetch failure; 502 blocked response without a description or refused redirect; 504 timeout. The shared errors below also apply.

Import bookmarks

POST /api/bookmarks/import

Request
bookmarks: required nonempty array, at most 2000 rows. Each row accepts title, url, description, note and tags, as described in Import behavior below. Other bookmark fields are not imported.
200 response
{ imported: number, skipped: number, duplicates: number, invalid: number, truncated: number, droppedTags: number, limitReached: boolean, invalidRows: [{ index: number, error: string }], membership_tier: string, bookmark_limit: number | null }
Behavior and errors
400 invalid/empty array or too many rows; 403 when already at the free limit; 413 body too large; 429 budget exhausted; 503 if a row carries a note before the notes migration; 500 partial import (error plus summary counts, without tier/limit fields). The shared errors below also apply.

Import behavior

Import skips invalid rows and URLs already in your live library or repeated in the request. Rows need nonblank string titles and valid HTTP(S) URLs. Invalid objects, field types and overlong URLs are skipped. Overlong titles, descriptions and notes are truncated; a non-string note makes the row invalid. Import lowercases tags and replaces whitespace with hyphens; non-string, overlong and surplus tags are dropped, while blank and duplicate tags are ignored.

truncated counts rows changed by truncation; droppedTags counts discarded tag entries. invalidRows includes zero-based indexes and reasons for the first 20 invalid rows. skipped includes invalid, duplicate and over-limit rows. Accepted rows commit in chunks of up to 250. A failed chunk returns 500 with counts so far; retrying the same file skips already-imported URLs. Paid accounts receive bookmark_limit: null.

Use the library GET for export; there is no export endpoint. HTML/CSV parsing and output formats are handled by the web app, not these JSON routes.

# bookmarks.json must contain {"bookmarks":[{"title":"Example","url":"https://example.com/","tags":["reading"]}]}
curl --fail-with-body 'https://tagsta.sh/api/bookmarks/import' \
  -H "Authorization: Bearer $TAGSTASH_API_KEY" \
  -H 'Content-Type: application/json' \
  --data-binary @bookmarks.json

Limits and per-account budgets

The free plan allows 50 live bookmarks; paid accounts have no bookmark-count limit. Create returns 403 once full. Import returns 403 if already full, otherwise admits what fits and reports limitReached: true for the remainder. Restore returns 403 with limitReached: true, restored: 0 and remainingSlots if it would exceed the limit. Trashed bookmarks do not use slots. Concurrent saves, imports and restores share the same limit.

Input limits from the library API
FieldMaximum
Title (after trimming)1000
URL (after normalization)8192
Description10000
Note10000
Each tag name100
Tags per bookmark50
API key name in Settings100
Rows per import2000
Ids per bulk request (before de-duplication)500

String limits count JavaScript UTF-16 units. Tag count limits apply after normalizing and de-duplicating. Ordinary parsed JSON bodies are capped at 262,144 bytes (256 KiB); imports at 8,388,608 bytes (8 MiB).

  • Page metadata: 60 lookups per 10 minutes and 1000 per 24 hours, shared by /meta and /meta-description.
  • Imports: 20 requests per hour.

Budgets belong to the account, across keys and sessions, and count attempts after the budget check, including downstream failures. They use rolling windows and return 429 when exhausted. These are soft limits: simultaneous requests may overshoot, and a counter-storage failure allows requests through. There is no general requests-per-minute budget defined for the other library routes.

Metadata fetches refuse private/local addresses and URL credentials, check every redirect (up to five), accept only HTML/XHTML and read at most 512 KiB, stopping at the end of the head, with an eight-second deadline. Scraped text is entity-decoded and clamped to bookmark field limits.

Errors and retrying

API errors are JSON with an error string. Some responses add fields, such as restore-limit details or import counts. Read the status and fields instead of matching message text.

{ "error": "Invalid or expired token" }
400
Invalid JSON/object, missing or invalid fields, invalid ids or URL. Correct the request before retrying.
401
No usable authentication credential: { "error": "Access token required" }.
403
Invalid, malformed, unknown or revoked key, an unavailable account, a forbidden key-auth route, or a plan limit. Invalid credentials use { "error": "Invalid or expired token" }.
404
Route, bookmark or tag not found or not available to you; also absent metadata or a hidden public profile.
413
Request body exceeds the byte cap: { "error": "Request body is too large" }.
415
Nonempty parsed body is not application/json or a +json media type: { "error": "Request body must be application/json" }.
422
A page metadata URL returned a successful response with a non-HTML content type.
429
Per-account metadata or import budget exhausted. Wait for the relevant rolling window; no Retry-After header is supplied.
500
Unexpected server error ({ "error": "Something went wrong!" }), a metadata fetch failure, or an import stopped partway with summary counts. A missing database binding also returns 500.
502
A page blocked the metadata request without usable metadata, or redirected to an unsafe URL or too many times; upstream service failure.
503
Required configuration or feature migration is unavailable.
504
Page metadata fetch timed out.

Library responses use Cache-Control: no-store. There is no library idempotency-key mechanism: after an uncertain result, check the library before retrying a create. Favorite and privacy routes are toggles, so blindly retrying reverses a successful request. Bulk actions skip inaccessible ids rather than revealing other users’ data.

Public profile API

GET /api/profiles/:username is a stable, keyless endpoint for embedding public bookmarks on another website. Send no Authorization header or cookies. The owner must enable their public profile in Settings. Missing, private and suspended profiles all return the same 404 JSON error.

Repeat ?tag= to filter by tags; every tag must match (AND). Values are trimmed, lowercased and de-duplicated in first-seen order. The returned tags list always describes the entire public collection, even when bookmarks are filtered. Bookmarks are newest created_at first, with no pagination. Notes are never included.

# Replace USERNAME and the tag names with a public profile's values.
curl --fail-with-body --get 'https://tagsta.sh/api/profiles/USERNAME' \
  --data-urlencode 'tag=reference' \
  --data-urlencode 'tag=reading'
{
  profile: { username: string, member_since: string, indexable: boolean },
  bookmarks: [{
    id: number, title: string, url: string, description: string | null,
    favicon_url: string | null, created_at: string, updated_at: string,
    tags: [{ id: number, name: string }]
  }],
  tags: [{ name: string, count: number }],
  appliedTags: string[]
}

Only public, non-private, non-trashed bookmarks are included. This response omits owner ids, favorite/privacy flags and account contact details. indexable reports whether the public profile meets the search-indexing bar; it does not control whether you can fetch it.

Browser CORS is Access-Control-Allow-Origin: *. Successful GET responses may be held in the Cloudflare edge cache for 60 seconds, with Cache-Control: public, max-age=0, s-maxage=60. A recent edit, deletion, privacy change or suspension can remain visible for up to a minute; takedown is not instant. Errors are not cached.

Up to 10 normalized tag filters of at most 100 characters each are cacheable; larger filters are served uncached. Cache keys include the username and normalized tags; extra query parameters, cookies and Authorization do not create fresh cache entries. Where the Cache API is available, X-Tagstash-Cache: HIT or MISS helps diagnose caching. A cache failure falls back to an uncached response.

Success is 200; hidden or missing profiles return 404. Shared server errors also apply. No account key is needed for this endpoint.