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.