Everything saved in a Tweetsmash library is reachable by software: a REST API at https://api.tweetsmash.com/v1, an MCP server at https://mcp.tweetsmash.com/api/mcp, and a keyless sandbox that answers with the same shapes so you can build the integration before you point it at anyone's real bookmarks.
Three requests, in order. The first needs nothing at all.
curl https://api.tweetsmash.com/v1/sandbox/bookmarks
Same envelope, same pagination meta, same field names as the real endpoint. Nothing behind it is real.
Generate a key on the API integrations page, then send it as a bearer token.
curl https://api.tweetsmash.com/v1/bookmarks?limit=5 \ -H "Authorization: Bearer $TWEETSMASH_API_KEY"
{
"status": true,
"data": [ { "id": "...", "text": "...", "author": { "username": "..." } } ],
"meta": { "count": 5, "next_cursor": "5" }
}Add the MCP server to any client that speaks the Model Context Protocol and the same library shows up as tools. Configuration is in the MCP section below.
Keys are created, listed and revoked on the API integrations page of your account. A key is shown once at creation; store it somewhere you can read it back, because we cannot show it to you again. Send it on every request as Authorization: Bearer <key>.
Revoking a key on that page takes effect immediately. If a key is missing or revoked the API answers 401 with a WWW-Authenticate challenge pointing at the protected-resource metadata, so an agent can discover how to authenticate without being told.
One honest limitation: a key today carries every permission the account has. Per-key scopes are not implemented yet, and the metadata document says so rather than advertising scopes that are not enforced. Treat a Tweetsmash key as full account access and store it accordingly.
The full machine-readable handshake — discover, register, use, revoke — is documented at auth.md.
https://api.tweetsmash.com/v1/sandbox mirrors the documented public surface and answers from fixtures. It takes no key, reads no account and writes nothing. Every response carries X-Tweetsmash-Environment: sandbox and a meta.sandbox: true flag, so a client can never mistake it for production.
Available there: GET /bookmarks, GET /bookmarks/:id, GET /labels, POST /labels/add, POST /labels/remove, POST /labels/batch, GET /views, GET /views/:slug, GET /views/:slug/bookmarks, the three POST /exports/* routes and GET /exports/:job_id.
You cannot write a retry path you have never seen fire. Send X-Tweetsmash-Sandbox-Error and the sandbox returns that failure on demand, with the real status code, the real error code and the real headers.
curl https://api.tweetsmash.com/v1/sandbox/bookmarks \
-H "X-Tweetsmash-Sandbox-Error: rate_limited" -i
HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-Tweetsmash-Environment: sandbox
{ "status": false, "code": "RATE_LIMITED", "error": "Rate limited (simulated)" }Supported values: unauthorized, subscription_required, filter_not_allowed, pagination_not_allowed, invalid_request, not_found, rate_limited, server_error. An unknown value returns 400 listing the ones that work.
The MCP server exposes the same library as tools — list, search, label, build Smart Views, trigger exports, read digests. It speaks streamable HTTP and authenticates with the same API key.
{
"mcpServers": {
"tweetsmash": {
"url": "https://mcp.tweetsmash.com/api/mcp",
"headers": { "Authorization": "Bearer YOUR_API_KEY" }
}
}
}For clients that only launch local processes, the same server ships on npm:
npx -y @tweetsmash/mcp
Every tool carries MCP annotations, so a client knows before it calls whether a tool only reads, whether it destroys data, and whether it reaches outside your library. The full tool list with those annotations is in the server card. Setup walkthrough: X Bookmarks MCP server.
Human reference: API documentation. Everything else is written for software:
Every failure returns the same envelope: status: false, a stable machine code, and error holding a sentence written for a person. Branch on code, never on error — the sentence can change, the code will not.
{ "status": false, "code": "SUBSCRIPTION_REQUIRED",
"error": "A paid plan is required to modify labels.",
"upgrade_url": "https://www.tweetsmash.com/pricing" }| Code | HTTP | What to do |
|---|---|---|
| UNAUTHORIZED | 401 | The key is missing, malformed or revoked. Check the Authorization header. |
| SUBSCRIPTION_REQUIRED | 402 | The endpoint needs a paid plan. The response carries upgrade_url. |
| FILTER_NOT_ALLOWED | 402 | Filtering is a paid capability. Retry without the filter, or upgrade. |
| PAGINATION_NOT_ALLOWED | 402 | Paging past the free window needs a paid plan. |
| VECTOR_SEARCH_NOT_ALLOWED | 402 | Semantic search needs a paid plan and a built vector store. |
| INVALID_REQUEST | 400 | A parameter is missing or the wrong shape. The error sentence names it. |
| NOT_FOUND | 404 | No such bookmark, label or Smart View on this account. |
| RATE_LIMITED | 429 | Slow down and retry after the Retry-After header. |
| INTERNAL_ERROR | 500 | Our fault. Safe to retry; use an Idempotency-Key on writes. |
Every 402 also carries a top-level upgrade_url. An agent that hits a paywall should surface that link to its user rather than retrying.
Send an Idempotency-Key header on any POST, PUT or PATCH. The first request with a given key is executed and its response stored for 24 hours; a repeat of the same key returns that stored response and sets Idempotency-Replayed: true instead of doing the work twice.
curl -X POST https://api.tweetsmash.com/v1/labels/add \
-H "Authorization: Bearer $TWEETSMASH_API_KEY" \
-H "Idempotency-Key: 6f3a9c2e-1b7d-4a55-9f10-2c8e5b0d4471" \
-H "Content-Type: application/json" \
-d '{"tweet_ids":["1000000000000000001"],"label_name":"ai"}'Reusing a key with a different body is a bug on the caller's side, so it is rejected with 422 rather than silently replayed. Keys are scoped to your account. Use a fresh UUID per logical operation, and reuse it only when retrying that same operation.
Exports do not block. POST /exports/pdf, /exports/csv and /exports/json answer 202 Accepted with a Location header, a Retry-After, and a poll_url in the body. Poll GET /exports/{job_id} until status is completed, then read download_url.
POST /v1/exports/pdf -> 202 Accepted
Location: /v1/exports/job_01H...
Retry-After: 5
GET /v1/exports/job_01H... -> 200 { "status": "pending" }
GET /v1/exports/job_01H... -> 200 { "status": "completed", "download_url": "..." }Honour Retry-After. Download links are short-lived; fetch the file when the job completes rather than storing the URL.
POST /labels/batch takes up to 50 label operations in one request so an agent tidying a library does not make fifty round trips. Each operation reports its own result, and a request where some operations failed returns 207 Multi-Status rather than pretending the whole thing succeeded or the whole thing failed.
POST /v1/labels/batch
{
"operations": [
{ "op": "add", "tweet_ids": ["100..."], "label_name": "ai" },
{ "op": "remove", "tweet_ids": ["200..."], "label_name": "todo" }
]
}
207 Multi-Status
{
"status": true,
"data": {
"results": [
{ "index": 0, "op": "add", "ok": true, "affected": 1, "data": [ ... ] },
{ "index": 1, "op": "remove", "ok": false,
"error": { "code": "INVALID_REQUEST", "message": "label_name is required." } }
],
"succeeded": 1, "failed": 1
},
"meta": { "message": "1 of 2 operations failed." }
}There is no request-rate limit on the API today, and we would rather say that than publish RateLimit-* headers describing a ceiling that does not exist. What does gate you is your plan: free accounts get a small number of API calls and cannot use filtering, pagination beyond the first window, or semantic search. Those refusals arrive as 402 with the code that names which capability was blocked, never as a silent empty list.
Be a good client anyway: keep concurrency modest, back off on 5xx, and honour Retry-After if you ever receive it. If we introduce rate limiting we will ship the standard headers and announce it under the policy below.
The API is versioned in the path. /v1 is the current and only version.
Within /v1 we will not remove a field, rename a field, change the type of a field, remove an endpoint, or add a required request parameter. Treat these as non-breaking and build to tolerate them: new fields appearing in a response, new optional request parameters, new error codes, new enum values, and changes to the human-readable error sentence.
A deprecated endpoint keeps working and starts answering with two headers: Deprecation carrying the date the deprecation took effect, and Sunset carrying the date it will stop working, both as HTTP dates. A Link header with rel="deprecation" points at the note explaining what to move to.
Deprecation: Wed, 01 Oct 2026 00:00:00 GMT Sunset: Sun, 29 Mar 2027 00:00:00 GMT Link: <https://www.tweetsmash.com/developers#versioning>; rel="deprecation"
The minimum window between Deprecation and Sunset is 180 days. Removing a whole version means a new path prefix, the old prefix marked deprecated on the same terms, and email to every account with a key that called it in the previous 90 days. No endpoint is ever removed without a Sunset header having been served first.
Questions, a bug in the API, or something in these docs that does not match what the server actually did: support@tweetsmash.com. Include the endpoint, the error code and roughly when it happened.
API reference · Bookmarks API · MCP server · Pricing · Terms