Get started with a personal key
- Sign in and open Settings → API Keys.
- Optionally name the key, then select Generate API Key. You can have up to 10 active keys.
- 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"
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"
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| Field | Maximum |
|---|
| Title (after trimming) | 1000 |
|---|
| URL (after normalization) | 8192 |
|---|
| Description | 10000 |
|---|
| Note | 10000 |
|---|
| Each tag name | 100 |
|---|
| Tags per bookmark | 50 |
|---|
| API key name in Settings | 100 |
|---|
| Rows per import | 2000 |
|---|
| 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.