A self-destructing note API with no plaintext endpoint

Your code encrypts and the server stores only ciphertext. Why this self-destructing note API has no plaintext endpoint, a Web Crypto example, and shortcuts.

The Evergist team · · 5 min read

Short answer: Evergist has a self-destructing note API at https://evergist.com/api/v1 with no API key, no account, and no endpoint that accepts plaintext. Your code encrypts the note with AES-256-GCM and uploads ciphertext, then builds the share link by putting the key after #. If you don't want to write the crypto, the JavaScript module and the CLI do it in one call.

What does the API look like?

Four endpoints do the work. The full reference is on the API page, and there is an OpenAPI spec for generators and agents.

Endpoint What it does Auth
POST /api/v1/gists Stores ciphertext with an expiry and a view limit None
GET /api/v1/gists/{id}/status Returns expiry, views left, and whether a password is set. Does not use a view Bearer access token
GET /api/v1/gists/{id} Returns the ciphertext and uses one view Bearer read token
DELETE /api/v1/gists/{id} Deletes the note, returns 204 Bearer delete token or read token

A create request carries no readable text:

{
  "v": 1,
  "iv": "3q2-7wAAAAAAAAAA",
  "ciphertext": "k0Qb...base64url...",
  "accessHash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
  "password": false,
  "expiresIn": 3600,
  "maxViews": 1
}

The response gives you id, url, expiresAt, maxViews, and a deleteToken. The url has no key in it. You append # and the base64url key yourself, which is why the server never has it.

Why is there no plaintext endpoint?

Because an endpoint that takes plaintext makes the operator able to read every note, whatever it does with the text afterwards.

Several note and secret APIs work that way. Doppler Share encrypts in the browser on its web page, but its API route POST /v1/share/secrets/plain takes the secret as is and encrypts on the server, per Doppler's API reference. Onetime Secret encrypts on the server with a key from its own configuration, per the project's encryption audit document. Both are reasonable designs if you trust the operator, and Onetime Secret is open source and self-hostable, which Evergist is not. Our comparison post has the full table.

We went the other way. The cost lands on you, because integrating code has to run HKDF and AES-GCM before it calls us. The payoff is that our server holds ciphertext, an expiry, a view counter, and token hashes, and nothing that decrypts them.

How do I create a note with Web Crypto?

Generate a 32-byte key, derive an encryption key and an access token from it with HKDF-SHA256, encrypt, and post. This runs in Node.js 20+, Deno, Bun, and browsers. Save it as create.mjs and run node create.mjs.

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 hex = (b) => [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, "0")).join("");

async function hkdf(ikm, info) {
  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, 256));
}

const k = crypto.getRandomValues(new Uint8Array(32)); // the link key, never uploaded
const aes = await crypto.subtle.importKey("raw", await hkdf(k, "evergist/v1/enc"), "AES-GCM", false, ["encrypt"]);
const access = await hkdf(k, "evergist/v1/access");
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt(
  { name: "AES-GCM", iv, additionalData: enc.encode("evergist/v1") },
  aes,
  enc.encode("db password: correct-horse"),
);

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", access)),
    password: false,
    expiresIn: 3600,
    maxViews: 1,
  }),
});
if (!res.ok) throw new Error(JSON.stringify(await res.json()));
const created = await res.json();
console.log(`${created.url}#${b64(k)}`);

The server stores accessHash, the SHA-256 of the access token. A reader derives the same token from the link key and sends it as Authorization: Bearer <token>. Someone who knows only the note ID gets a 404.

This is the create half. The API page has the complete example with the read, and the encryption scheme has the password derivation, which adds PBKDF2-SHA256 at 600,000 iterations.

Is there a shortcut that skips the crypto?

Yes, three of them.

The JavaScript module. An ES module served from our domain. Runtimes that can import from a URL, such as Deno and browsers, can use it directly. Node doesn't import from HTTPS URLs, so download the file and import it locally, or use the CLI.

import { createGist, readGist } from "https://evergist.com/sdk/evergist.js";

const gist = await createGist({ text: "deploy key: ...", maxViews: 1, expiresIn: 3600 });
console.log(gist.url); // full link, key included

const { text, burned } = await readGist(gist.url);

The CLI. One dependency-free file, run through npx. --json makes it easy to call from any language that can spawn a process.

printf '%s' "$SECRET" | npx -y evergist create --views 1 --expires 1h --json
npx -y evergist status "https://evergist.com/g/<id>#<key>"
npx -y evergist read "https://evergist.com/g/<id>#<key>"

The Python reader. A short script that reads a note with the cryptography package and none of our code. It is a working reference if you are porting the scheme to another language.

pip install cryptography
curl -fsSL https://evergist.com/examples/evergist_read.py -o evergist_read.py
python3 evergist_read.py "https://evergist.com/g/<id>#<key>"

Sharing secrets from the terminal goes deeper on the CLI, and the agent guide covers the local MCP server.

What about limits and errors?

A note holds up to 512 KiB of UTF-8 text, lasts 1 minute to 30 days, and allows 1 to 1,000 views or no view limit. Every error returns the same JSON shape, with a machine-readable error and a human-readable message. The API reference has the full tables under limits and errors.

Two behaviors are worth planning for. The 404 cases look identical on purpose, so a client can't tell a burned note from a wrong key, and neither can anyone probing IDs. And reads are destructive when a view limit is set. If a read uses the last view, the server deletes the note before it responds and sets burned to true. A retry gets 404, so write the plaintext somewhere before you parse it. Call /status first when you only need to know whether a link still works.

Text only. No files, no accounts, no webhooks, no list endpoint. You can't enumerate notes you created, so keep the link and the deleteToken if you need them later.

Questions

Is there an API for self-destructing notes that never receives plaintext?

Yes. The Evergist API accepts only an IV, ciphertext, and token hashes. The client runs AES-256-GCM before the request, and the key stays in the URL fragment, which the client adds to the link after the server replies. You can confirm it by reading the request body in your own code or in the browser's network tab.

Do I need an API key or an account?

No. Creating a note needs no authentication. Reading, checking status, and deleting use a bearer token derived from the link key, so holding the link is the permission. Rate limits apply per network.

Can I send a file or binary data?

No. Notes are UTF-8 text up to 512 KiB. You could base64 a small binary yourself, but the web viewer will show the encoded text. For files, use a tool built for them.

Can I self-host the API?

Not today. The CLI accepts --base-url and EVERGIST_URL to point at another Evergist server, but we don't publish a self-hosted server. If self-hosting is a requirement, PrivateBin, Yopass, and Enclosed all encrypt on the client and can run on your own hardware.