# REST API

> Create, read, and delete end-to-end encrypted, expiring notes over HTTP. Endpoints, errors, limits, and a complete Web Crypto example. Ciphertext only.

Source: https://evergist.com/docs/api/
Updated: 2026-09-29

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.

## Before you start

The API only moves ciphertext. To create a gist you need to run the [v1 encryption scheme](https://evergist.com/security/#the-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](https://evergist.com/agents/), 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](https://evergist.com/openapi.json)

## Create a gist

`POST /api/v1/gists`

```json
{
  "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`:

```json
{
  "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.

```json
{
  "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`.

```json
{
  "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:

```json
{ "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.

```js
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](https://evergist.com/security/#the-encryption-scheme) has the exact derivation, and the [Python reader](https://evergist.com/examples/evergist_read.py) shows it in full.