Developers

REST API

A small JSON API for storing and fetching ciphertext. You encrypt before you call it, so the server never sees your text. No API key, no account.

Updated ยท Markdown

Before you start

The API only moves ciphertext. To create a gist you need to run the v1 encryption scheme on your side: generate a key, derive an encryption key and tokens with HKDF, and encrypt with AES-256-GCM. If you'd rather not implement that, use the CLI, MCP server, or JavaScript module, which do it for you.

  • Base URL: https://evergist.com/api/v1
  • Format: JSON in, JSON out. Binary values are unpadded base64url.
  • Auth: none to create. Reads use a bearer token derived from the link key.
  • CORS: open to all origins. No cookies are used.
  • Machine-readable spec: openapi.json

Create a gist

POST /api/v1/gists

{
  "v": 1,
  "iv": "3q2-7wAAAAAAAAAA",
  "ciphertext": "k0Qb...base64url...",
  "accessHash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
  "password": false,
  "expiresIn": 3600,
  "maxViews": 1
}
Field Type Notes
v 1 Scheme version
iv string 12 bytes, base64url (16 characters)
ciphertext string AES-256-GCM output with the 16-byte tag, base64url. At most 512 KiB plus 16 bytes when decoded
accessHash string Hex SHA-256 of the access token
password boolean Whether a password is mixed into the key
proofHash string Hex SHA-256 of the password proof. Required when password is true, forbidden otherwise
iterations integer PBKDF2 iterations, 100,000 to 10,000,000. Required when password is true. Clients use 600,000
expiresIn integer Seconds until deletion, 60 to 2,592,000 (30 days). Default 86,400
maxViews integer or null Reads allowed before deletion, 1 to 1,000. null means no limit. Default null

Response 201 Created:

{
  "id": "7mQx2Ld9KpTa",
  "url": "https://evergist.com/g/7mQx2Ld9KpTa",
  "expiresAt": "2026-09-29T13:00:00.000Z",
  "maxViews": 1,
  "deleteToken": "Hq3...43 characters..."
}

Build the share link by appending # and the base64url link key to url. Keep deleteToken private: it deletes the gist without the link.

Check a gist's status

GET /api/v1/gists/{id}/status with Authorization: Bearer <access token>

Doesn't use a view. Readers call this first to learn whether they need a password.

{
  "id": "7mQx2Ld9KpTa",
  "password": true,
  "iterations": 600000,
  "maxViews": 1,
  "viewsRemaining": 1,
  "expiresAt": "2026-09-29T13:00:00.000Z"
}

Read a gist

GET /api/v1/gists/{id} with Authorization: Bearer <read token>

The read token is the access token, or access.proof when the gist has a password. This call uses one view. If it was the last one, the gist is deleted before the response is sent and burned is true.

{
  "id": "7mQx2Ld9KpTa",
  "v": 1,
  "iv": "3q2-7wAAAAAAAAAA",
  "ciphertext": "k0Qb...",
  "password": false,
  "maxViews": 1,
  "viewsRemaining": 0,
  "burned": true,
  "expiresAt": "2026-09-29T13:00:00.000Z"
}

A wrong password proof returns 401 with attemptsRemaining. After 10 wrong attempts the gist deletes itself.

Delete a gist

DELETE /api/v1/gists/{id} with Authorization: Bearer <delete token or read token>

Returns 204 No Content. The creator can use the delete token. A reader can use the same read token they'd use to open it.

Other endpoints

Endpoint Returns
GET /api/v1 A short index of endpoints
GET /api/v1/health { "ok": true }
GET /api/v1/stats The public daily counters: page loads, gists created, gists read

Errors

Errors share one shape:

{ "error": "not_found", "message": "Gist not found. It expired, reached its view limit, was deleted, or the link key is wrong." }
Status error When
400 invalid_json, invalid_request The body is malformed or a field is out of range. message says which
401 invalid_password Wrong password proof. Includes attemptsRemaining
404 not_found Gone, never existed, or a wrong access token. These look identical on purpose
413 too_large Plaintext over 512 KiB
415 unsupported_media_type Missing content-type: application/json
429 rate_limited Over a rate limit. message says which. Wait and retry

Limits

Limit Value
Plaintext size 512 KiB (524,288 bytes) of UTF-8
Expiry 1 minute to 30 days
View limit 1 to 1,000, or none
Wrong passwords 10, then the gist is deleted
Creates 10 per minute and 120 per hour from one network
Reads and deletes 120 per minute from one network

Complete example with Web Crypto

This runs as is in Node.js 20+, Deno, Bun, and modern browsers. It creates a gist and reads it back.

const API = "https://evergist.com/api/v1";
const enc = new TextEncoder();
const b64 = (b) => btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const unb64 = (s) => Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
const hex = (b) => [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, "0")).join("");

async function hkdf(ikm, info, bytes) {
  const key = await crypto.subtle.importKey("raw", ikm, "HKDF", false, ["deriveBits"]);
  const params = { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: enc.encode(info) };
  return new Uint8Array(await crypto.subtle.deriveBits(params, key, bytes * 8));
}

async function keysFor(k) {
  const aes = await crypto.subtle.importKey("raw", await hkdf(k, "evergist/v1/enc", 32), "AES-GCM", false, ["encrypt", "decrypt"]);
  const access = b64(await hkdf(k, "evergist/v1/access", 32));
  return { aes, access };
}

// Create
const k = crypto.getRandomValues(new Uint8Array(32));
const { aes, access } = await keysFor(k);
const iv = crypto.getRandomValues(new Uint8Array(12));
const aad = enc.encode("evergist/v1");
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: aad }, aes, enc.encode("hello from Web Crypto"));
const res = await fetch(`${API}/gists`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({
    v: 1,
    iv: b64(iv),
    ciphertext: b64(ct),
    accessHash: hex(await crypto.subtle.digest("SHA-256", unb64(access))),
    password: false,
    expiresIn: 3600,
    maxViews: 1,
  }),
});
const created = await res.json();
const link = `${created.url}#${b64(k)}`;
console.log(link);

// Read (uses the one allowed view)
const key = unb64(new URL(link).hash.slice(1));
const r = await keysFor(key);
const sealed = await (await fetch(`${API}/gists/${created.id}`, { headers: { authorization: `Bearer ${r.access}` } })).json();
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: unb64(sealed.iv), additionalData: aad }, r.aes, unb64(sealed.ciphertext));
console.log(new TextDecoder().decode(pt), sealed.burned);

Password-protected gists add PBKDF2 on top. The security page has the exact derivation, and the Python reader shows it in full.