by VividEngine

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 & pathAuthConsumes view?Purpose
POST /api/jotoptional API keyn/aCreate 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}/metanoneNoMetadata 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}/unlocknoneNoVerify 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 optionalYes (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 optionalYes (burn/limit)text/plain body, hardened headers.
HEAD on any read pathnoneNoStatus 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 keyNoUpdate 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 tokenNoDelete the jot now. Returns 204.
GET /api/key/challengenonen/aA signed proof-of-work challenge { seed, exp, difficulty, sig }: valid 5 minutes, redeemable once, only from the same IP.
POST /api/keynone (PoW)n/aRedeem a solved challenge (body { seed, exp, difficulty, sig, nonce }) for an API key. No registration.
GET /api/jotsAPI keyNoList 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}noneNoSocial share card (image/svg+xml). Protected or view-limited jots get a generic card without content.
GET /embed/{id}noneNoAn iframe-embeddable card. Shows content only for unrestricted jots; protected or view-limited jots show a placeholder.
POST /api/reportnonen/aReport 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 /healthznonen/aReturns { 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/ABC12345

Lifecycle

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

Status codes

StatuscodeMeaning
200 / 201 / 202 / 204—Success: read, create (jot or key), report accepted, delete.
400invalid_json, invalid_payload, invalid_*, empty_content, reason_too_long, expiration_not_allowed, invalid_cursor, invalid_proof_of_work, pow_reused, not_protectedBad 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.
401unauthorized, invalid_api_key, password_required, wrong_passwordMissing/invalid credentials, or the jot is locked.
403forbidden, cross_originWrong 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.
404not_foundNever existed, deleted, or its storage was reclaimed. Also any unknown /api/* path.
409idempotency_conflict, idempotency_in_progressIdempotency-Key reused with a different body (including a different password), or the first request is still running.
410gone, challenge_expiredExpired, burned, or view limit reached (until storage is reclaimed, then 404). A PoW challenge past its 5-minute TTL.
413payload_too_large, content_too_largeRequest 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.
429rate_limitedA rate limit or the daily byte quota tripped. Honor Retry-After.
451removedTaken down for abuse. Permanent — never reverts to 404.
500 / 503internal_error, index_write_failed, id_allocation_failed, not_configured, service_unavailableServer-side failure (500: internal_error, index_write_failed; 503: the rest); safe to retry (use an Idempotency-Key for creates).