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

API base URLs
InstanceBase URLWrites
Self-hostedhttp://localhost:8080Open by default. Restrict them with WRITE_API_TOKEN.
Hostedhttps://api.snappwd.ioOpen. 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 encryptedSecret as 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.iv and the ciphertext as base64 in encryptedData.
  • 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 with spf-. Treat the rest as opaque. An ID with the wrong prefix returns 404.
  • expiration is a lifetime in seconds, from 60 (1 minute) to 2592000 (30 days). There is no default: it is required.
  • Retrieval is one-time. A GET without peek returns the payload and deletes it in the same step, so two simultaneous requests cannot both succeed.
  • ?peek=true returns 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 sets WRITE_API_TOKEN.

POST /v1/secrets

Store an encrypted secret. Returns its ID.

POST /v1/secrets request fields
FieldTypeRequiredDescription
encryptedSecretstringyesThe ciphertext. At most 1,572,864 UTF-8 bytes (1.5 MiB), including any base64 or envelope overhead.
expirationintegeryesLifetime in seconds, 60 to 2592000.
metadataobjectnoOptional 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.
senderEmailstringnoOne 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.

GET /v1/secrets/{id} responses
RequestResponse 200Effect
GET /v1/secrets/{id}{"encryptedSecret": "..."}The secret is deleted.
GET /v1/secrets/{id}?peek=truecreatedAt (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.

POST /v1/files request fields
FieldTypeRequiredDescription
encryptedDatastringyesThe encrypted file, base64 encoded. The encrypted size before base64 may not exceed MAX_FILE_SIZE_MB (2 MiB by default).
metadata.originalFilenamestringyesFilename to restore on download. Stored unencrypted.
metadata.contentTypestringyesMIME type. Stored unencrypted.
metadata.ivstringyesInitialization vector used to encrypt the file. Stored unencrypted, which is normal for an IV.
expirationintegeryesLifetime in seconds, 60 to 2592000.
senderEmailstringnoSame 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.

GET /v1/files/{id} responses
RequestResponse 200Effect
GET /v1/files/{id}metadata, encryptedData and createdAtThe file is deleted.
GET /v1/files/{id}?peek=truecreatedAt, ttlSeconds and metadataNothing 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

HTTP status codes returned by the API
StatusWhen
200Success, for both creation and retrieval.
400Expiration out of range, secret or file too large, an invalid notification email, malformed JSON, or an invalid query parameter.
401Missing or wrong write token on an instance that sets WRITE_API_TOKEN. Includes WWW-Authenticate: Bearer.
404Unknown ID, wrong ID prefix, already retrieved, or expired. The API does not say which.
413Request body over the transport limit (2 MiB for secrets). Plain text.
415POST without Content-Type: application/json.
422Body is valid JSON but a field is missing, has the wrong type, or the metadata is not allowed.
429Write rate limit reached for your IP. Retry-After gives the wait in seconds. Invalid requests count towards the limit.
500Unexpected error while reading from storage.
503Storage is full or unavailable, or senderEmail was sent to an instance without SMTP.

Limits

Size, time and rate limits
LimitValueConfigurable
Expiration60 seconds to 30 daysno
encryptedSecret1,572,864 UTF-8 bytes (1.5 MiB)no
POST /v1/secrets body2,097,152 bytes (2 MiB)no
Encrypted file, before base642 MiBMAX_FILE_SIZE_MB
Secret metadata4096 serialized JSON bytesno
Writes per IP10 per minuteWRITE_REQUESTS_PER_MINUTE
Stored data128 MiB and 10,000 keysSTORAGE_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

The peek above needed no key. Do not put anything in 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 68

OpenAPI 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.