SnapPwd API reference
The SnapPwd API has four endpoints: create and retrieve a secret, and create and retrieve a file. It stores ciphertext until it is retrieved once or expires, and it never receives the decryption key. Your client encrypts before it uploads and decrypts after it downloads.
This page describes snappwd-service, the open-source API. Every example was run against a local instance on .
Base URL
| Instance | Base URL | Writes |
|---|---|---|
| Self-hosted | http://localhost:8080 | Open by default. Restrict them with WRITE_API_TOKEN. |
| Hosted | https://api.snappwd.io | Open. Write rate limits apply. |
The examples below use a local instance. The self-hosting guide shows how to start one with Docker Compose. The hosted instance is the one behind snappwd.io and the default for the SnapPwd CLI. It accepted writes without a token when we checked on October 12, 2026, and it serves the same /openapi.yaml as the repository. This page makes no promise about its rate limits or availability. If an integration matters to you, run your own instance.
The API stores ciphertext only
The API does not encrypt anything. It stores the string you send in encryptedSecret or encryptedData as it is, and returns it once. If you send plaintext, plaintext is what gets stored. Encrypting, and keeping the key away from the server, is the client's job. The security architecture page explains the model.
The SnapPwd CLI is the reference client. Its crypto.ts does the following, and links it creates against the hosted API open in the hosted web app:
- Generate 32 random bytes as an AES-256-GCM key, and a random 12-byte initialization vector for each payload.
- For a secret, send
encryptedSecretas the base64 encoding of one version byte (0x02), then the initialization vector, then the ciphertext with its authentication tag. - For a file, send the initialization vector as base64 in
metadata.ivand the ciphertext as base64 inencryptedData. - Put the key, base58 encoded, in the URL fragment of the share link:
/g/<id>#<key>. The fragment is never part of an API request.
The API does not check this format. A client that only talks to itself can use any scheme. The self-hosted web client uses a different one, described under known limitations.
Conventions
- Request and response bodies are JSON. POST requests must send
Content-Type: application/json. - Secret IDs start with
sps-and file IDs start withspf-. Treat the rest as opaque. An ID with the wrong prefix returns404. expirationis a lifetime in seconds, from60(1 minute) to2592000(30 days). There is no default: it is required.- Retrieval is one-time. A GET without
peekreturns the payload and deletes it in the same step, so two simultaneous requests cannot both succeed. ?peek=truereturns the creation time, the seconds left and the metadata, without the payload and without deleting anything. It needs only the ID, not the key.- Errors raised by the service's own checks have the shape
{"error": "..."}. Requests rejected before they reach those checks may return a plain-text message instead: malformed JSON or an invalid query parameter (400), an oversized body (413), a missing JSON content type (415) and a wrong or missing field (422). Do not assume an error body is JSON; check the status code first. - Reads need no authentication. Writes need
Authorization: Bearer <token>only when the instance setsWRITE_API_TOKEN.
POST /v1/secrets
Store an encrypted secret. Returns its ID.
| Field | Type | Required | Description |
|---|---|---|---|
encryptedSecret | string | yes | The ciphertext. At most 1,572,864 UTF-8 bytes (1.5 MiB), including any base64 or envelope overhead. |
expiration | integer | yes | Lifetime in seconds, 60 to 2592000. |
metadata | object | no | Optional labels, stored and returned unencrypted. Allowed string fields and their limits in UTF-8 bytes: label (120), intendedRecipient (254), note (1000), rotateBy (64), rotationReason (240), shareType (64). Values are trimmed and empty ones dropped. Unknown fields, non-string values and an object over 4096 serialized bytes return 422. |
senderEmail | string | no | One email address, at most 254 bytes, to notify when the secret is retrieved. Never returned by the API. Returns 503 if the instance has no SMTP configuration. |
Response 200:
{"secretId":"sps-JfMLhNZ939fMhYWmE37twN"}GET /v1/secrets/{id}
Retrieve a secret and delete it. Add ?peek=true to read its status instead.
| Request | Response 200 | Effect |
|---|---|---|
GET /v1/secrets/{id} | {"encryptedSecret": "..."} | The secret is deleted. |
GET /v1/secrets/{id}?peek=true | createdAt (Unix seconds), ttlSeconds (seconds until expiry) and metadata (only present if some was stored) | Nothing is deleted. |
Both forms return 404 once the secret has been retrieved or has expired. When the sender asked for a notification, a successful retrieval emails them the caller's IP address and user agent. A peek never does.
POST /v1/files
Store an encrypted file. Returns its ID.
| Field | Type | Required | Description |
|---|---|---|---|
encryptedData | string | yes | The encrypted file, base64 encoded. The encrypted size before base64 may not exceed MAX_FILE_SIZE_MB (2 MiB by default). |
metadata.originalFilename | string | yes | Filename to restore on download. Stored unencrypted. |
metadata.contentType | string | yes | MIME type. Stored unencrypted. |
metadata.iv | string | yes | Initialization vector used to encrypt the file. Stored unencrypted, which is normal for an IV. |
expiration | integer | yes | Lifetime in seconds, 60 to 2592000. |
senderEmail | string | no | Same as for secrets. |
Response 200:
{"fileId":"spf-SzJyjzvbSXn6JLHkVRaUjY"}GET /v1/files/{id}
Retrieve a file and delete it. Add ?peek=true to read its metadata instead.
| Request | Response 200 | Effect |
|---|---|---|
GET /v1/files/{id} | metadata, encryptedData and createdAt | The file is deleted. |
GET /v1/files/{id}?peek=true | createdAt, ttlSeconds and metadata | Nothing is deleted. |
The filename and content type come from whoever created the file. Treat them as untrusted input: escape them before display and do not use the filename as a path without checking it.
Status codes
| Status | When |
|---|---|
200 | Success, for both creation and retrieval. |
400 | Expiration out of range, secret or file too large, an invalid notification email, malformed JSON, or an invalid query parameter. |
401 | Missing or wrong write token on an instance that sets WRITE_API_TOKEN. Includes WWW-Authenticate: Bearer. |
404 | Unknown ID, wrong ID prefix, already retrieved, or expired. The API does not say which. |
413 | Request body over the transport limit (2 MiB for secrets). Plain text. |
415 | POST without Content-Type: application/json. |
422 | Body is valid JSON but a field is missing, has the wrong type, or the metadata is not allowed. |
429 | Write rate limit reached for your IP. Retry-After gives the wait in seconds. Invalid requests count towards the limit. |
500 | Unexpected error while reading from storage. |
503 | Storage is full or unavailable, or senderEmail was sent to an instance without SMTP. |
Limits
| Limit | Value | Configurable |
|---|---|---|
| Expiration | 60 seconds to 30 days | no |
encryptedSecret | 1,572,864 UTF-8 bytes (1.5 MiB) | no |
| POST /v1/secrets body | 2,097,152 bytes (2 MiB) | no |
| Encrypted file, before base64 | 2 MiB | MAX_FILE_SIZE_MB |
| Secret metadata | 4096 serialized JSON bytes | no |
| Writes per IP | 10 per minute | WRITE_REQUESTS_PER_MINUTE |
| Stored data | 128 MiB and 10,000 keys | STORAGE_MAX_BYTES, STORAGE_MAX_KEYS |
Values are the service defaults. A hosted or third-party instance may be configured differently.
curl examples
These use a placeholder string where your ciphertext goes. The output shown is what a local instance returned. IDs and timestamps will differ for you.
Create, peek at and retrieve a secret
curl -s -X POST http://localhost:8080/v1/secrets \
-H 'Content-Type: application/json' \
-d '{"encryptedSecret":"AgECAwQFBgcICQoLDGNpcGhlcnRleHQ=","expiration":3600}'
# {"secretId":"sps-JfMLhNZ939fMhYWmE37twN"}curl -s 'http://localhost:8080/v1/secrets/sps-JfMLhNZ939fMhYWmE37twN?peek=true'
# {"createdAt":1791828484,"ttlSeconds":3600}curl -s http://localhost:8080/v1/secrets/sps-JfMLhNZ939fMhYWmE37twN
# {"encryptedSecret":"AgECAwQFBgcICQoLDGNpcGhlcnRleHQ="}
curl -s http://localhost:8080/v1/secrets/sps-JfMLhNZ939fMhYWmE37twN
# {"error":"Secret not found or already accessed"} (HTTP 404)Attach metadata
curl -s -X POST http://localhost:8080/v1/secrets \
-H 'Content-Type: application/json' \
-d '{"encryptedSecret":"AgECAwQFBgcICQoLDGNpcGhlcnRleHQ=","expiration":600,"metadata":{"label":"API key for staging","rotateBy":"2026-11-01"}}'
# {"secretId":"sps-JHuwxQUE3v9BtoqU2YEU4T"}
curl -s 'http://localhost:8080/v1/secrets/sps-JHuwxQUE3v9BtoqU2YEU4T?peek=true'
# {"createdAt":1791828484,"ttlSeconds":600,"metadata":{"label":"API key for staging","rotateBy":"2026-11-01"}}Metadata is public to anyone with the ID
metadata, or in a filename, that you would not want a person who intercepts the link to read.Create, peek at and retrieve a file
curl -s -X POST http://localhost:8080/v1/files \
-H 'Content-Type: application/json' \
-d '{"metadata":{"originalFilename":"notes.txt","contentType":"text/plain","iv":"AQIDBAUGBwgJCgsM"},"encryptedData":"Y2lwaGVydGV4dA==","expiration":3600}'
# {"fileId":"spf-SzJyjzvbSXn6JLHkVRaUjY"}curl -s 'http://localhost:8080/v1/files/spf-SzJyjzvbSXn6JLHkVRaUjY?peek=true'
# {"createdAt":1791828484,"ttlSeconds":3600,"metadata":{"originalFilename":"notes.txt","contentType":"text/plain","iv":"AQIDBAUGBwgJCgsM"}}
curl -s http://localhost:8080/v1/files/spf-SzJyjzvbSXn6JLHkVRaUjY
# {"metadata":{"originalFilename":"notes.txt","contentType":"text/plain","iv":"AQIDBAUGBwgJCgsM"},"encryptedData":"Y2lwaGVydGV4dA==","createdAt":1791828484}Write to a token-protected instance
On an instance started with WRITE_API_TOKEN, a POST without the token returns 401 and {"error":"Valid write token required"}. Send the token as a bearer token. Retrieval still needs none.
curl -s -X POST http://localhost:8080/v1/secrets \
-H 'Authorization: Bearer 0123456789abcdef0123456789abcdef' \
-H 'Content-Type: application/json' \
-d '{"encryptedSecret":"AgECAwQFBgcICQoLDGNpcGhlcnRleHQ=","expiration":3600}'
# {"secretId":"sps-GVBbHVW1rTeQBCtR5PBnQx"}The token above is an example value. Generate your own random one and send it only over HTTPS.
What errors look like
# expiration below 60 seconds: HTTP 400
{"error":"Invalid expiration time"}
# senderEmail on an instance without SMTP: HTTP 503
{"error":"Email notifications are unavailable"}
# more than 10 writes within a minute: HTTP 429, with a retry-after header
{"error":"Write rate limit exceeded"}
# a metadata field that is not allowed: HTTP 422, plain text
Failed to deserialize the JSON body into the target type: metadata: metadata contains unknown field `owner` at line 1 column 68OpenAPI specification
The machine-readable definition is openapi.yaml in the service repository. Every running instance also serves it at GET /openapi.yaml. The explorer below is generated from the same file.