API

Base URL https://timelock.dev. No authentication. CORS is open (Access-Control-Allow-Origin: *), so you can call it from any site.

The server sees your plaintext. These endpoints encrypt and decrypt on our Worker, so the plaintext passes through it in memory. It's never logged or stored, but you have to trust that. For anything sensitive, use the website (encrypts in your browser) or a local CLI. Both produce identical, interchangeable ciphertext.

POST /api/encrypt

Encrypts a message so it can only be decrypted at or after a drand quicknet round. Give exactly one of duration, unlockAt or round.

curl -s https://timelock.dev/api/encrypt \
  -H 'Content-Type: application/json' \
  -d '{"plaintext": "hello, future", "duration": "5m"}'

Request body

FieldTypeDescription
plaintextstring, requiredThe message. Up to 64 KiB after decoding.
encoding"utf8" | "base64"How plaintext is encoded. Default "utf8". Use base64 for binary data.
durationnumber | stringSeconds from now (300) or a duration string: "90s", "15m", "2h30m", "3 days 6 hours". Units: s, m, h, d, w, y (365 days).
unlockAtnumber | stringUnix seconds, or ISO 8601 with a time zone: "2030-01-01T00:00:00Z", "2030-01-01T09:00:00+09:00". Must be in the future.
roundintegerAn exact quicknet round. Past rounds are allowed; the result is decryptable immediately.

Times are rounded up to the next round (one every 3 seconds), so unlockAt in the response can be up to 3 s later than requested. The maximum is 100 years ahead.

Response 200

{
  "ciphertext": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHRsb2Nr…\n-----END AGE ENCRYPTED FILE-----\n",
  "round": 33275913,
  "unlockAt": "2026-10-22T01:05:03.000Z",
  "unlockAtUnix": 1792631103,
  "chainHash": "52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971",
  "network": "quicknet"
}

More examples

# unlock at a specific moment
curl -s https://timelock.dev/api/encrypt \
  -d '{"plaintext": "Happy new year!", "unlockAt": "2030-01-01T00:00:00Z"}'

# binary data, ciphertext saved to a file (needs jq)
curl -s https://timelock.dev/api/encrypt \
  -d "{\"plaintext\": \"$(base64 -w0 photo.jpg)\", \"encoding\": \"base64\", \"duration\": \"30d\"}" \
  | jq -r .ciphertext > photo.jpg.age

POST /api/decrypt

Decrypts a ciphertext once its round has been published. Accepts anything produced by this site, the API, dee, tlock or tlock-js for quicknet (armored form).

curl -s https://timelock.dev/api/decrypt \
  -H 'Content-Type: application/json' \
  -d "$(jq -Rs '{ciphertext: .}' message.age)"

Request body

FieldTypeDescription
ciphertextstring, requiredArmored age text, -----BEGIN AGE ENCRYPTED FILE----- … -----END AGE ENCRYPTED FILE-----.
encoding"base64"Optional. Force base64 output even when the plaintext is valid UTF-8.

Response 200

{
  "plaintext": "hello, future",
  "encoding": "utf8",
  "round": 33275913,
  "unlockAt": "2026-10-22T01:05:03.000Z",
  "unlockAtUnix": 1792631103
}

Plaintext that isn't valid UTF-8 comes back as base64 with "encoding": "base64".

Too early: 425

{
  "error": {
    "code": "too_early",
    "message": "Too early: this message unlocks at drand round 33275913 (2026-10-22T01:05:03.000Z).",
    "round": 33275913,
    "unlockAt": "2026-10-22T01:05:03.000Z",
    "unlockAtUnix": 1792631103
  }
}

GET /api/events

The public activity feed, newest first. It contains no message content, ciphertext or IP addresses.

curl -s 'https://timelock.dev/api/events?limit=2'
QueryDescription
limit1 to 100, default 20.
beforeReturn events with an id lower than this (pagination: pass the previous nextBefore).
{
  "events": [
    {
      "id": 42,
      "type": "decrypt",
      "source": "api",
      "createdAt": "2026-10-22T01:06:10.512Z",
      "round": 33275913,
      "unlockAt": "2026-10-22T01:05:03.000Z"
    },
    {
      "id": 41,
      "type": "encrypt",
      "source": "web",
      "createdAt": "2026-10-22T01:00:01.204Z",
      "round": 33275913,
      "unlockAt": "2026-10-22T01:05:03.000Z"
    }
  ],
  "nextBefore": 41
}

source is "web" (browser-side, reported by the website after the fact) or "api" (recorded by the server). nextBefore is null on the last page. Responses may be cached for 5 seconds. POST /api/events is used by the website itself and only accepts same-origin requests.

Original endpoints

The endpoints of the original Rust worker still work unchanged for existing scripts: raw bytes in, raw bytes out, no JSON. Uploads can be up to 5 MiB.

POST /encrypt/:round

Body: the raw plaintext (any bytes). Response: armored age ciphertext (Content-Type: application/octet-stream). Look up the round for a time with /api/encrypt or dee.

curl -s -X POST --data-binary @original.png https://timelock.dev/encrypt/33275913 > encrypted.age

POST /decrypt

Body: armored or binary age ciphertext. Response: the raw plaintext bytes.

curl -s -X POST --data-binary @encrypted.age https://timelock.dev/decrypt > decrypted.png

Errors on these endpoints are plain text with the HTTP status codes listed below (for example 425 when it's too early).

Errors

JSON endpoints return {"error": {"code": "…", "message": "…"}} with extra fields where noted.

StatusCodeMeaning
400invalid_jsonBody isn't a JSON object.
400invalid_requestA field is missing or invalid; `message` says which.
403forbiddenCross-origin call to an endpoint reserved for the website.
405method_not_allowedWrong HTTP method.
413payload_too_largeBody or plaintext over the limit; includes `limitBytes`.
422invalid_ciphertextNot a tlock age ciphertext, or it's corrupted.
422unsupported_chainCiphertext was made for a drand chain other than quicknet.
425too_earlyUnlock round not reached yet; includes `round`, `unlockAt`, `unlockAtUnix`.
500internal_errorUnexpected server error.
502drand_unavailableNo drand relay answered. Retry with backoff.
503feed_unavailableThe activity feed database isn't configured.

Limits