API
Every endpoint speaks JSON. Errors return { "error": "...", "code": "..." } with the right HTTP status (see status codes), except a /raw/{id} rate-limit 429 and every /api/og/{id} error, which are text/plain. Authenticate with Authorization: Bearer <owner_token | api_key>.
Endpoints
| Method & path | Auth | Consumes view? | Purpose |
|---|---|---|---|
POST /api/jot | optional API key | n/a | Create a jot. Optional password protects it. Returns id, url, raw_url, owner_token (shown once), expires_at, view_limit, burn_after_read, syntax, exposure, size_bytes, is_password_protected. |
GET /api/jot/{id}/meta | none | No | Metadata only (no body): id, syntax, exposure, created_at, updated_at, expires_at, view_limit, views_remaining, burn_after_read, rich_links, is_password_protected, size_bytes, line_count, state. The "look before you consume" call. |
POST /api/jot/{id}/unlock | none | No | Verify a password. A JSON body { "password": "..." } returns 200 with an unlock_token (over 4 KB is 413 payload_too_large), even with Accept: text/html. A same-origin application/x-www-form-urlencoded browser form (multipart is not accepted) gets a 303 back to /{id}: with the unlock cookie on success, or an ?unlock= error flag on any failure, including an oversize body. Throttled per IP and per jot; only failed guesses count toward the per-jot limits (the per-IP limit counts every attempt), and each jot also has a daily cap on wrong guesses, of which each source (one address, or one Cloudflare Worker zone) gets only a share, so a single source can't use it up alone. |
GET /api/jot/{id} | owner optional | Yes (burn/limit) | The /meta fields plus content. Reads with the owner token (Bearer <owner_token>) never consume. A read with an API key is not an owner read: it consumes like any other. A password-protected jot returns 401 until unlocked (no view consumed). |
GET /raw/{id} | owner optional | Yes (burn/limit) | text/plain body, hardened headers. |
HEAD on any read path | none | No | Status and headers with an empty body, including /api/jot/{id} and /embed/{id}. Never consumes a view. |
PATCH /api/jot/{id} | owner token or API key | No | Update content, syntax, exposure, view_limit, expiration, password. A key that does not authorize the jot is 403 forbidden. When the edit is otherwise authorized (owner token) but the Bearer key is not this jot's own key, key-only expiry (never, over 365 days, or an empty expiration, which means never) is 400 expiration_not_allowed. Sending content counts against the daily byte quota (may return 429); a rejected edit (any 4xx, including 403/404/410/451) is not charged; one that fails with a 5xx may have been applied and stays charged. view_limit null or "unlimited" clears the limit; 0 is 400 invalid_view_limit. Returns 200 with the /meta fields. |
DELETE /api/jot/{id} | owner token | No | Delete the jot now. Returns 204. |
GET /api/key/challenge | none | n/a | A signed proof-of-work challenge { seed, exp, difficulty, sig }: valid 5 minutes, redeemable once, only from the same IP. |
POST /api/key | none (PoW) | n/a | Redeem a solved challenge (body { seed, exp, difficulty, sig, nonce }) for an API key. No registration. |
GET /api/jots | API key | No | List the jots created with this key: ?limit= (default 25, max 100), ?cursor= from the previous next_cursor (null on the last page). Returns { jots, next_cursor }; each item is { id, url, raw_url, created_at, expires_at, syntax, exposure, view_limit, size_bytes, is_password_protected }. A page may be short or even empty; keep paging until next_cursor is null. |
GET /api/og/{id} | none | No | Social share card (image/svg+xml). Protected or view-limited jots get a generic card without content. |
GET /embed/{id} | none | No | An iframe-embeddable card. Shows content only for unrestricted jots; protected or view-limited jots show a placeholder. |
POST /api/report | none | n/a | Report abuse: { "reason": "...", "id": "...", "details": "..." } (reason required, max 120 chars). Returns 202. Reports are logged and reviewed from the logs; for urgent takedowns, email abuse@fast.io. |
GET /healthz | none | n/a | Returns { status, version, securityDisabled }. securityDisabled is informational only. |
Create a jot
Fields: content (required, max 512 KB); syntax (default plain); expiration — 10m | 1h | 1d | 1w | 1mo | 1y | never; view_limit — 1 | 5 | 25, or omit for unlimited (1 = burn after read); exposure — unlisted (default) | public (public is accepted and echoed, but currently behaves like unlisted: no listing); password (optional, at most 256 characters). syntax is at most 32 characters of A-Z a-z 0-9 + # . _ -.
Default expiration: omitting expiration means 1mo for anonymous creates and never (no expiry) when you send an API key. never requires an API key; anonymously it returns 400 expiration_not_allowed.
curl -X POST https://jotary.com/api/jot \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"content":"hello world","syntax":"plain","expiration":"1d","view_limit":1,"exposure":"unlisted"}'HTTP/1.1 201 Created
{
"id": "ABC12345",
"url": "https://jotary.com/ABC12345",
"raw_url": "https://jotary.com/raw/ABC12345",
"owner_token": "jot_owner_...",
"expires_at": 1767225600000,
"view_limit": 1,
"burn_after_read": true,
"syntax": "plain",
"exposure": "unlisted",
"size_bytes": 11,
"is_password_protected": false
}Python
import uuid, requests
r = requests.post(
"https://jotary.com/api/jot",
headers={"Idempotency-Key": str(uuid.uuid4())},
json={"content": "hello world", "syntax": "plain", "expiration": "1d"},
)
data = r.json()
# An anonymous idempotent replay omits owner_token.
print(data["url"], data.get("owner_token"))Node
const res = await fetch("https://jotary.com/api/jot", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({ content: "hello world", syntax: "plain", expiration: "1d" }),
});
const data = await res.json();
console.log(data.url, data.owner_token);Read a jot
Use /meta to inspect without consuming a view, then read content when you mean it. For burn/view-limited jots, a content read (GET /api/jot/{id} or /raw/{id}) consumes a view, so check /meta before handing a limited jot's raw URL to another agent; an owner-token (Bearer <owner_token>) read never consumes, and neither does a HEAD request. In the web reader, visitors to a view-limited jot see no Raw/Download links (each would spend another view); the content is on the page and can be copied.
# Inspect without consuming a view:
curl https://jotary.com/api/jot/ABC12345/meta
# Read content (consumes a view for burn/view-limited jots):
curl https://jotary.com/api/jot/ABC12345
# Raw text:
curl https://jotary.com/raw/ABC12345
# HEAD never consumes a view (status + headers only):
curl -I https://jotary.com/raw/ABC12345Lifecycle
- An expired, burned, or fully-viewed jot returns 410 until its storage is reclaimed, then 404. Reclaiming is not prompt (a jot consumed by reads or by a PATCH stays 410 until its original expiry, or, if it never expires, normally about 7 days after it is used up; deleting it makes it 404 at once), so treat both as gone.
- A deleted jot returns 404. A jot taken down for abuse returns 451 on every surface, permanently.
PATCHthat lowersview_limitto (or below) the views already served consumes the jot immediately; the PATCH response shows the new state and later reads return 410. Clearing the limit (null) and setting one again later starts a fresh count.- Reads of unrestricted jots may be served from Cloudflare's edge cache for up to 60 seconds, so an edit, a new password, a delete, or a takedown can take up to about a minute to reach every location.
Password protection
Pass password at create time to protect a jot. A protected jot's content surfaces (GET /api/jot/{id}, /raw, and the HTML reader) return 401 until you unlock — no view is consumed while locked, so a burn/view-limited jot survives a failed or absent unlock. Unlock with POST /api/jot/{id}/unlock to receive a short-lived unlock_token; re-present it on reads via the X-Unlock-Token header (agents) — browsers receive an unlock_{id} cookie automatically. The owner can change or clear the password with PATCH (clearing = an empty password); any change invalidates outstanding unlock tokens.
# Create a password-protected jot:
curl -X POST https://jotary.com/api/jot \
-H 'Content-Type: application/json' \
-d '{"content":"top secret","password":"hunter2"}'
# A protected read is 401 until you unlock (no view is consumed):
curl -i https://jotary.com/api/jot/ABC12345 # -> 401
# Unlock with the password to get a short-lived unlock_token:
curl -X POST https://jotary.com/api/jot/ABC12345/unlock \
-H 'Content-Type: application/json' \
-d '{"password":"hunter2"}'
# -> {"unlock_token":"ABC12345.<exp>.<ver>.<sig>","expires_at":<ms>}
# Re-present the token on reads via the X-Unlock-Token header:
curl https://jotary.com/api/jot/ABC12345 \
-H 'X-Unlock-Token: <unlock_token>'
# Owner can change/clear the password via PATCH (clearing = empty string):
curl -X PATCH https://jotary.com/api/jot/ABC12345 \
-H 'Authorization: Bearer <owner_token>' \
-H 'Content-Type: application/json' \
-d '{"password":""}'Edit & delete (owner token)
The owner_token is returned once on create. Keep it — it is required for DELETE and (unless you created the jot with an API key) for PATCH.
# Edit (owner token or the creating API key):
curl -X PATCH https://jotary.com/api/jot/ABC12345 \
-H 'Authorization: Bearer <owner_token>' \
-H 'Content-Type: application/json' \
-d '{"content":"updated body"}'
# Delete:
curl -X DELETE https://jotary.com/api/jot/ABC12345 \
-H 'Authorization: Bearer <owner_token>'API keys
An API key is a keyless identity handle — no signup. Get a challenge from GET /api/key/challenge, find a nonce such that SHA-256("<seed>:<nonce>") has at least difficulty (16) leading zero bits (about 65,000 hashes, well under a second), then POST /api/key with the challenge fields plus nonce as a JSON string (a numeric nonce is rejected as invalid_proof_of_work), from the same IP within 5 minutes. Each solved challenge is redeemable once (400 pow_reused, which does not count against the 5/day issuance cap); expired ones return 410 challenge_expired. Issuance is capped per IP per day.
A key lets you list the jots you created and PATCH them (DELETE always needs the owner token), use expiration: "never" (and makes it the default), and raises the daily byte quota. It never raises the per-IP create-count limit.
Python
# 1. Get a challenge, 2. solve the proof-of-work, 3. redeem it — from the SAME IP.
import hashlib, itertools, requests
ch = requests.get("https://jotary.com/api/key/challenge").json()
# -> {"seed": "...", "exp": <ms>, "difficulty": 16, "sig": "..."}
def zero_bits(digest):
return 256 - int.from_bytes(digest, "big").bit_length()
nonce = next(
str(i) for i in itertools.count()
if zero_bits(hashlib.sha256(f"{ch['seed']}:{i}".encode()).digest()) >= ch["difficulty"]
)
r = requests.post("https://jotary.com/api/key", json={**ch, "nonce": nonce})
api_key = r.json()["api_key"] # 201 {"api_key", "tier", "created_at"} — shown once
# List the jots created with the key (newest first, paginated):
jots = requests.get(
"https://jotary.com/api/jots?limit=25",
headers={"Authorization": f"Bearer {api_key}"},
).json()
# -> {"jots": [...], "next_cursor": "..." | null} (pass ?cursor=<next_cursor> for the next page)Node
import { createHash } from "node:crypto";
const ch = await (await fetch("https://jotary.com/api/key/challenge")).json();
const zeroBits = (buf) => {
let bits = 0;
for (const b of buf) {
if (b === 0) { bits += 8; continue; }
return bits + Math.clz32(b) - 24;
}
return bits;
};
let n = 0;
while (zeroBits(createHash("sha256").update(`${ch.seed}:${n}`).digest()) < ch.difficulty) n++;
const res = await fetch("https://jotary.com/api/key", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...ch, nonce: String(n) }),
});
const { api_key } = await res.json();Idempotency
Send an Idempotency-Key header on POST /api/jot. For 24 hours, a retry with the same key and an identical body (including the same password) replays the original 201 instead of creating a duplicate, as long as the original jot is still live. Once it has expired, been consumed, deleted or removed, the same key and body create a fresh jot. With an API key the replay includes the original owner_token. Anonymous replays omit owner_token (and set no owner cookie), because another client behind the same IP could otherwise claim it — keep the token from the first response. The same key with a different body returns 409 idempotency_conflict; while the first request is still running, 409 idempotency_in_progress. A create can also get that 409 on its own first attempt when the server couldn't confirm its record; the jot may exist, so retry with the same key and body to get its 201 (anonymous: without the owner token). A password-protected create made before the 2026-09 idempotency update answers an identical retry with 409 idempotency_conflict until its record expires (within 24 hours). Without an API key, a replay only matches requests from the same connecting IP address; use an API key for reliable retries. Callers on Cloudflare Workers all share one connecting address, so anonymous keys can collide across Workers: use an API key or keys unique to you (e.g. a UUID).
Limits
- Content: 512 KB per jot. Request bodies over about 3 MB (4 KB for key issuance and unlock, about 14 KB for reports) are rejected with 413.
- Creates: 10 per hour per IP (an API key never raises this; each key also has its own 60/hour budget). Without an
Idempotency-Key, a create rejected with 400, 401, or a 413 detected while reading the body still counts (a 413 from the declared Content-Length does not), and a create refused by the per-key 60/hour budget or the byte quota (429) keeps its per-IP create count. A create that sends an API key or anIdempotency-Keyalso counts against a 120/minute per-IP lookup limit. - Storage: 250 MB of content per IP per day, shared by keyed and anonymous requests, of which at most 25 MB may be anonymous — charged on create and on any
PATCHthat sendscontentunless it is rejected (any 4xx, including 403/404/410/451); one that fails with a 5xx stays charged. - Edits/deletes, unlock attempts, key issuance (5 per IP per day), listing, and reports are each throttled.
- Per-IP limits count an IPv6 /64 as one address. Every 429 carries a Retry-After header.
- For callers on Cloudflare Workers, the per-IP limits on creates, storage, key issuance, edits/deletes, unlock attempts, listing, reports and reads apply per calling Worker zone (the
CF-Workerheader) rather than to Cloudflare's one shared Worker address; a solved key challenge stays single-use per connecting address, not per zone.
Status codes
| Status | code | Meaning |
|---|---|---|
| 200 / 201 / 202 / 204 | — | Success: read, create (jot or key), report accepted, delete. |
| 400 | invalid_json, invalid_payload, invalid_*, empty_content, reason_too_long, expiration_not_allowed, invalid_cursor, invalid_proof_of_work, pow_reused, not_protected | Bad request. invalid_payload = the body is valid JSON but not an object (e.g. null or an array). empty_content = content is an empty string. reason_too_long = a report reason over 120 characters. pow_reused = that solved challenge was already redeemed. |
| 401 | unauthorized, invalid_api_key, password_required, wrong_password | Missing/invalid credentials, or the jot is locked. |
| 403 | forbidden, cross_origin | Wrong owner token/key; or a cross-site browser request to POST /api/jot, PATCH or DELETE /api/jot/{id}, or POST /api/jot/{id}/unlock, or POST /api/report (Sec-Fetch-Site: cross-site, or an Origin that is another site or null). Requests without an Origin header (curl, agents) are unaffected. |
| 404 | not_found | Never existed, deleted, or its storage was reclaimed. Also any unknown /api/* path. |
| 409 | idempotency_conflict, idempotency_in_progress | Idempotency-Key reused with a different body (including a different password), or the first request is still running. |
| 410 | gone, challenge_expired | Expired, burned, or view limit reached (until storage is reclaimed, then 404). A PoW challenge past its 5-minute TTL. |
| 413 | payload_too_large, content_too_large | Request body over the cap (about 3 MB for create/PATCH, 4 KB for key issuance and unlock, about 14 KB for reports), or content over 512 KB. |
| 429 | rate_limited | A rate limit or the daily byte quota tripped. Honor Retry-After. |
| 451 | removed | Taken down for abuse. Permanent — never reverts to 404. |
| 500 / 503 | internal_error, index_write_failed, id_allocation_failed, not_configured, service_unavailable | Server-side failure (500: internal_error, index_write_failed; 503: the rest); safe to retry (use an Idempotency-Key for creates). |