Cachet

API

Any website or app can use Cachet’s API for free to create and open timed, encrypted messages. Messages made through the API work with the Cachet keyboard and this website, and the other way round.

Base URLhttps://www.jlkrkre.cn
FormatJSON, UTF-8. Every endpoint allows cross-origin calls (CORS). No sign-up, no API key.
Rate limitsPer IP per minute: 60 creates, 600 reads; /v1/open with a password counts as 10 reads. You may use a minute's allowance at once. Beyond that you get 429 with Retry-After (seconds). An overloaded server answers 503 {"error":"busy"}, also with Retry-After: just try again shortly.

Two ways in

Zero-knowledge (recommended)Server-side encryption
EndpointsPOST /v1/keys
GET /v1/keys/{id}
GET /v1/keys/{id}/status
POST /v1/seal
POST /v1/open
Where encryption happensYour device or browser (with our SDK)Our server
Can the server see the text?NoYes (in memory only, never stored or logged)
Good forWebsites, apps, anything privacy-sensitiveBack-end scripts, bots, quick integrations

Option 1: the web SDK (zero-knowledge)

This site runs this very SDK: sdk/cachet.js (about 85 KB, unminified, ready to read). Put it on your own site and import it as an ES module:

import * as Cachet from './cachet.js';

// Seal: readable for 1 hour, optional access password and read-once
const { ciphertext, expiresAt } = await Cachet.seal('See you at eight', {
  ttl: 3600,            // seconds, 30 s to 30 days
  password: '',         // optional
  burn: false,          // true = opens only once
  style: 'han',         // 'han' | 'latin' | 'invisible'
  server: 'https://www.jlkrkre.cn',
});

// Open: pass a whole chat message, the SDK finds the sealed text itself
try {
  const { text, expiresAt, reads } = await Cachet.open(ciphertext, { password: '' });
} catch (e) {
  // e.code: not_cachet | password_required | wrong_password | expired | consumed | not_found
}

// Look without opening (doesn't count as a read)
const info = Cachet.inspect(ciphertext);   // { hasPassword, burn, ... } or null

Option 1: your own implementation

If you encrypt and decrypt yourself in another language, these are the only endpoints you need; the format is under Protocol.

POST /v1/keys: create a key

curl -X POST BASE/v1/keys -H 'Content-Type: application/json' \
  -d '{"ttl": 3600, "maxReads": 0}'
FieldMeaning
ttlLifetime in seconds, 30 to 2592000 (30 days)
maxReadsHow many reads are allowed; 0 means no limit. 1 for read-once, 100 at most
201 {"id": "Rk2x…", "key": "Q4m…", "expiresAt": 1791900000, "now": 1791896400}

id and key are unpadded base64url: id decodes to 12 bytes, key to 32.

GET /v1/keys/{id}: read a key

curl BASE/v1/keys/Rk2x…
200 {"key": "Q4m…", "expiresAt": 1791900000, "now": 1791896500, "reads": 1, "maxReads": 0}
410 {"error": "expired",  "expiresAt": …}       expired, key wiped
410 {"error": "consumed", "reads": 1, …}        read-once, already read
404 {"error": "not_found"}

Every successful read is counted; the read that reaches maxReads wipes the key right after.

GET /v1/keys/{id}/status: check status

curl BASE/v1/keys/Rk2x…/status
200 {"expiresAt": 1791900000, "now": 1791896500, "maxReads": 1}   still readable (reads omitted when 0)
410 {"error": "consumed", …}                     read-once, already read
410 {"error": "expired", …} / 404                as above

Like reading the key, but it returns no key and doesn’t count as a read. A sender can use it to see whether their read-once message has been opened.

Option 2: server-side encryption

Here our server does the encryption, so it briefly handles the plaintext in memory. We don’t store or log it, but if nobody else may ever touch your plaintext, use option 1.

POST /v1/seal: encrypt

curl -X POST BASE/v1/seal -H 'Content-Type: application/json' \
  -d '{"text": "See you at eight", "ttl": 3600, "password": "", "burn": false, "style": "han"}'
201 {"ciphertext": "「…」", "id": "Rk2x…", "expiresAt": 1791900000, "now": 1791896400}

text is at most 10,000 characters; style is han (default, shortest), latin or invisible.

POST /v1/open: decrypt

curl -X POST BASE/v1/open -H 'Content-Type: application/json' \
  -d '{"ciphertext": "From a friend: 「…」", "password": ""}'
200 {"text": "See you at eight", "expiresAt": 1791900000, "now": …, "reads": 1}
401 {"error": "password_required"}
403 {"error": "wrong_password"}      a wrong password doesn't count as a read
410 {"error": "expired" | "consumed"}
422 {"error": "not_cachet" | "private_message"}

Limits and retries

When many people share one IP (an office, carrier NAT), a client can get a free pass for an allowance of its own (one IP tops out at 600 creates and 6000 reads a minute). A pass lasts 24 hours. The server doesn't store it and it isn't tied to any identity. Fetch one only after a 429.

GET  /v1/pass/challenge  → 200 {"challenge": "…", "bits": 18, "now": …}
Find a decimal number n such that SHA-256(challenge + ":" + n) starts with at least bits zero bits
POST /v1/pass {"challenge": "…", "solution": "n"}  → 201 {"pass": "…", "expiresAt": …, "now": …}
Then send the header  X-Cachet-Pass: …

Retrying a read-once message: send X-Cachet-Read-Token (a random value per open, up to 64 characters) when reading a key. If the reply gets lost you can read again with the same value for 30 seconds, without counting another read; anyone else still gets 410 consumed. Without this header the key is destroyed as it's read.

Protocol

The complete format, for zero-knowledge implementations in other languages. The Android, iOS, server and web implementations are checked byte for byte against the same test vectors.

envelope = 0x18 ‖ flags(1) ‖ keyId(12) ‖ AES-256-GCM(K, nonce = 12 × 0x00, aad = first 14 bytes, plaintext)
flags:     bit0 = access password, bit1 = read once
K =        HKDF-SHA256(ikm = key, salt = keyId, info = "cachet v2 public", 32)
password:  ikm = key ‖ PBKDF2-SHA256(UTF-8(password), salt = keyId, 100000 iterations, 32)
plaintext = 1 format byte ‖ content; 0x00 UTF-8, 0x01 UTF-16BE, 0x02 raw DEFLATE(UTF-8)

The envelope bytes are then written as text: