Documentation
How It Works
Zephr encrypts plaintext on your device, stores only ciphertext, and deletes records after one view.
Key Creation
The browser generates a 256-bit Web Crypto API AES-GCM key from device entropy before any network communication.
The raw key bytes are encoded as a versioned string (v1.<base64url>)
and embedded in the shareable link's URL fragment, then immediately zeroed from application
memory. Due to JavaScript engine behavior, deterministic memory sanitization cannot be ensured.
Encryption
The plaintext is encrypted on your device using the generated key. Each operation uses a unique 96-bit initialization vector (IV). Plaintext never leaves the local environment.
AES-GCM provides both confidentiality and integrity. The ciphertext includes an authentication tag that detects any tampering. Identical inputs produce different outputs due to the random IV.
The encrypted data contains the IV, ciphertext, and authentication tag — everything needed for decryption except the key.
Storage
The encrypted data is transmitted over HTTPS and stored with a cryptographically random 128-bit identifier and expiration metadata. No encryption keys or plaintext data are stored.
The server accepts an encrypted blob, assigns it a random identifier, and returns it on request. It cannot decrypt the blob or verify its contents.
Each stored record contains:
The blob is opaque without the key, which never reaches the server.
The Link
The link has two parts: a path identifying the stored blob, and a URL fragment containing
the versioned decryption key (v1.<base64url>).
URL fragments (everything after the # symbol) are never sent to servers.
This is a fundamental property of URIs defined in RFC 3986.
The key travels directly from sender to recipient, bypassing Zephr's servers entirely.
Split mode separates URL and key for transmission through different channels. Both parts are required — URL alone or key alone grants nothing.
Decryption
When the link is opened, the recipient's browser extracts the raw key bytes from the URL fragment and imports them into the Web Crypto API as a non-extractable AES-GCM CryptoKey. Once imported, no code — including the SDK itself — can read back the raw key material; the browser's cryptographic subsystem enforces this boundary. The browser then requests the encrypted blob using the identifier. The server returns the ciphertext and marks the record as consumed in a single atomic operation, preventing successful concurrent retrievals.
AES-GCM verifies the authentication tag as part of decryption. If the data was tampered with, decryption fails completely. The plaintext appears only in that browser, only in memory.
A second attempt returns 404 (never existed) or 410 (consumed or expired). The data is gone.
Destruction
After access, the server marks the record as consumed — it immediately returns 410 on any subsequent request. Physical deletion follows asynchronously via background cleanup. Unaccessed records are removed automatically when they expire.
No decryption keys are stored server-side. Any backups contain only encrypted blobs Zephr cannot decrypt.
Security Layers
- AES-GCM-256 authenticated encryption — confidentiality and tamper detection in one operation
- Non-extractable CryptoKey — the decryption key is imported as non-extractable at retrieval time; the browser's cryptographic subsystem prevents key extraction by any code
- URL fragment isolation — keys travel client-to-client, never touching the server (RFC 3986)
- Atomic one-time access — concurrent requests cannot retrieve the same record twice
- HTTPS with HSTS — transport encryption enforced on every request
- Per-IP rate limiting — brute force and enumeration attacks are blocked at the API layer
No single control is relied upon. These properties emerge from architecture, not policy. See security boundaries for what Zephr cannot control.
Quickstart
Four paths to a one-time link. The MCP server works in any MCP-compatible agent IDE. The CLI needs no installation for one-off use. The JS SDK works isomorphically in Node.js 22+ and browser bundles. The Python SDK is the right choice for Python applications and agent workflows.
MCP (Claude Desktop, Cursor, Windsurf)
npx zephr-mcp
# Then in your agent: "Store this API key as a one-time secret."
CLI
npm install -g zephr
zephr "my-api-key-abc123"
# → https://zephr.io/secret/Ht7kR2mNqP3wXvYz8aB4cD#v1.key...
JavaScript / TypeScript
npm install zephr
import { createSecret } from 'zephr';
const { fullLink } = await createSecret('my-api-key-abc123');
// → https://zephr.io/secret/Ht7kR2mNqP3wXvYz8aB4cD#v1.key...
Python
pip install zephr
import zephr
result = zephr.create_secret("my-api-key-abc123")
print(result.full_link)
# → https://zephr.io/secret/Ht7kR2mNqP3wXvYz8aB4cD#v1.key...
MCP Server
Use Zephr as a native tool in Claude Desktop, Cursor, Windsurf, and any MCP-compatible agent runtime. Requires Node.js 22 or later.
npx zephr-mcp
The API key is optional — anonymous mode works for testing (3 creates/day, expiry up to 1 hour,
including the 15-minute window used in the example below).
Create a free key at zephr.io/account and pass it via the
ZEPHR_API_KEY environment variable to unlock the longer expiry values (24 h, 7 d, 30 d),
plus larger payloads and webhook callbacks.
Configuration
Add the following to your MCP host's configuration file. The JSON is the same for all hosts — only the file path differs.
{
"mcpServers": {
"zephr": {
"command": "npx",
"args": ["zephr-mcp"],
"env": { "ZEPHR_API_KEY": "zeph_..." }
}
}
}
| Host | Config file |
|---|---|
| Claude Desktop | ~/Library/Application Support/Claude/claude_desktop_config.json |
| Claude Code | .mcp.json (project root), claude mcp add, or .claude/settings.local.json |
| Cursor | .cursor/mcp.json |
| Windsurf | ~/.windsurf/mcp.json |
| VS Code (Copilot) | .vscode/mcp.json |
| Kiro | .kiro/settings/mcp.json |
| Other MCP hosts | See your host's MCP configuration docs |
Claude Code note: Claude Code's MCP system is separate from VS Code's built-in MCP panel. Place a .mcp.json file at your project root with the standard mcpServers config shown above. Claude Code will prompt to approve the server on first use, or set "enableAllProjectMcpServers": true in .claude/settings.local.json to auto-approve. You can also register globally: claude mcp add zephr -- npx zephr-mcp.
Example
Once configured, ask your agent:
"Store this database password as a one-time secret with a 15-minute expiry and hint DB_PROD."
→ Link: https://zephr.io/secret/Ht7kR2mNqP3wXvYz8aB4cD#v1.key...
Expires: 2026-03-20T14:15:00.000Z
Secret ID: Ht7kR2mNqP3wXvYz8aB4cD
Hint: DB_PROD
Tools
zephr_create_secret — Encrypt a secret client-side and store only ciphertext. Returns a one-time link.
| Parameter | Type | Required | Description |
|---|---|---|---|
secret |
string |
· | Plaintext to encrypt (max 14,336 UTF-8 bytes) |
expiry |
integer |
Minutes: 5, 15, 30, 60, 1440, 10080, 43200. Default: 60. Without a ZEPHR_API_KEY, values up to 60 are permitted. |
|
hint |
string |
Plaintext label for routing and audit. 1-128 printable ASCII. Not encrypted. | |
split |
boolean |
Return URL and key separately. Default: false |
zephr_retrieve_secret — Retrieve and decrypt a one-time secret. The record is permanently destroyed. Provide link (standard mode) or both url + key (split mode).
| Parameter | Type | Required | Description |
|---|---|---|---|
link |
string |
Full Zephr link with key in fragment | |
url |
string |
Secret URL without key (split mode, requires key) |
|
key |
string |
Encryption key (split mode, requires url) |
Returns newline-delimited text: the decrypted plaintext on the first line,
followed by Hint: <label> and Purge at: <timestamp> when present.
Encryption happens in the MCP server process — plaintext never reaches the Zephr server.
CLI
Handles encryption and upload in a single command. No dependencies — uses Node.js built-ins only. Reads from stdin, so it composes naturally with password managers, scripts, and automation pipelines. Links open in any browser — the CLI and web app are fully interoperable.
# Global install
npm install -g zephr
# No install required (one-off use, CI, agent environments)
npx zephr "my-secret"
zephr <secret> [options] Create a one-time secret
echo "secret" | zephr [options] Create from stdin
zephr retrieve <link> [options] Retrieve and decrypt
Create options:
-e, --expiry <minutes> Expiration in minutes (default: 60)
Anonymous: 5, 15, 30, 60. With an API key: any value.
-s, --split Return URL and key separately
-H, --hint <label> Plaintext label for routing/audit (1-128 ASCII, not encrypted)
-k, --api-key <key> API key; takes precedence over ZEPHR_API_KEY env var
Retrieve options:
--url <url> Secret URL (split mode)
--key <key> Encryption key (split mode)
-k, --api-key <key> API key
General:
-v, --version Show version
-h, --help Show help
Examples
zephr "my-api-key" # 1h expiry (default)
zephr "my-api-key" --expiry 60 # 1h expiry
zephr "my-api-key" --split # URL and key separately
zephr "my-api-key" --hint STRIPE_KEY # attach a plaintext label
# Pipe from stdin — scripts, CI pipelines, and agent environments
echo "$API_KEY" | zephr --expiry 60 # from environment variable
pass show production/db | zephr # from password manager
Authentication
The CLI works without an account. Anonymous requests are capped at 3 per day per IP, a maximum expiry of 1 h, and a 6 KB encrypted blob. Pass an API key to lift all three. Keys are created at zephr.io/account and are free.
| Tier | Creates | Expiry options | Max secret |
|---|---|---|---|
| Anonymous | 3/day | 5m, 15m, 30m, 1h | 6 KB blob (~4 KB plaintext) |
| Free | 500/month | 5m, 15m, 30m, 1h, 24h, 7d, 30d | 20 KB blob (14 KB plaintext) |
# Flag — takes precedence over the environment variable
zephr "secret" --api-key zeph_...
# Environment variable — preferred for CI and automation
export ZEPHR_API_KEY=zeph_...
zephr "secret" --expiry 10080
# Sub-hour values work anonymously; 24h and longer require an API key
zephr "secret" --expiry 15
ZEPHR_API_KEY=zeph_... zephr "secret" --expiry 43200
Exit codes
| Code | Meaning |
|---|---|
0 | Success |
1 | Any error — invalid input, network failure, or upload error |
JavaScript SDK
Isomorphic — works in Node.js 22+ and any modern browser via bundler.
The zephr npm package ships both the CLI binary and the importable
library, so no separate install is needed. TypeScript declarations are bundled.
Zero external dependencies — uses globalThis.crypto.subtle and
fetch, both available natively in Node.js 22+ and all modern browsers.
npm install zephr
import { createSecret, retrieveSecret } from 'zephr';
createSecret()
| Option | Type | Required | Description |
|---|---|---|---|
secret |
string |
· | Plaintext to encrypt. Max 14,336 UTF-8 bytes. |
expiry |
5 | 15 | 30 | 60 | 1440 | 10080 | 43200 |
Minutes until expiration. Default: 60. Anonymous callers may use up to 60 (5, 15, 30, 60); any account unlocks 24 h, 7 d, and 30 d. |
|
split |
boolean |
Return URL and key separately. Default: false |
|
apiKey |
string | null |
Bearer token for authenticated requests. Default: null |
|
hint |
string |
Plaintext label for routing and audit. 1-128 printable ASCII. Not encrypted. | |
callbackUrl |
string |
HTTPS webhook URL — receives a signed event on consumption. Requires authentication and callbackSecret. |
|
callbackSecret |
string |
HMAC-SHA256 signing secret for the webhook. Required when callbackUrl is set. Max 256 chars. |
|
idempotencyKey |
string |
Caller-provided idempotency key (1-64 alphanumeric + hyphens). Auto-generated if omitted. Cache TTL: 24 hours. |
Return value — standard mode
{
mode: 'standard',
fullLink: 'https://zephr.io/secret/Ht7kR2mNqP3wXvYz8aB4cD#v1.key...',
expiresAt: '2026-02-21T12:00:00.000Z',
secretId: 'Ht7kR2mNqP3wXvYz8aB4cD'
}
Return value — split mode
{
mode: 'split',
url: 'https://zephr.io/secret/Ht7kR2mNqP3wXvYz8aB4cD',
key: 'v1.base64url-key...',
expiresAt: '2026-02-21T12:00:00.000Z',
secretId: 'Ht7kR2mNqP3wXvYz8aB4cD'
}
Errors
| Error class | Raised when |
|---|---|
ValidationError | Input is invalid — empty, too long, wrong type, or unsupported expiry |
EncryptionError | AES-GCM key generation or encryption failed |
ApiError | Server error. Has .statusCode and .code properties. |
NetworkError | Connection failed or timed out (10 s) |
Error handling
import { createSecret, ValidationError, ApiError, NetworkError } from 'zephr';
try {
const { fullLink } = await createSecret('my-api-key', { expiry: 60 });
console.log(fullLink);
} catch (e) {
if (e instanceof ValidationError) console.error('Invalid input:', e.message);
else if (e instanceof ApiError) console.error(`Server error ${e.statusCode}:`, e.message);
else if (e instanceof NetworkError) console.error('Network error:', e.message);
else throw e;
}
TypeScript
import { createSecret, retrieveSecret } from 'zephr';
import type { SecretLink, RetrievalResult, CreateSecretOptions, RetrieveSecretOptions } from 'zephr';
const result: SecretLink = await createSecret('my-api-key', { expiry: 60 });
if (result.mode === 'standard') {
console.log(result.fullLink); // string — key embedded in fragment
} else {
console.log(result.url, result.key); // both strings — deliver separately
}
const { plaintext }: RetrievalResult = await retrieveSecret(result.fullLink);
retrieveSecret()
Fetch and decrypt a one-time secret. The server marks the record consumed on first read —
a second call with the same link throws an ApiError with statusCode 410.
The decryption key never leaves the local environment; the server only ever holds ciphertext.
| Option | Type | Required | Description |
|---|---|---|---|
link |
string | { url, key } |
· | Full link string, or { url, key } for split mode. |
apiKey |
string | null |
Bearer token for authenticated requests. Default: null |
Returns — RetrievalResult
An object with plaintext (string — the decrypted secret),
hint (string or undefined — the plaintext label set by the creator),
and purgeAt (number or undefined — a Unix timestamp in seconds for when the record will be permanently deleted).
Example — standard mode
import { createSecret, retrieveSecret } from 'zephr';
// Agent A — encrypt and share
const { fullLink } = await createSecret('sk-live-abc123', { expiry: 60, hint: 'STRIPE_KEY_PROD' });
// Agent B — retrieve (atomic; destroys the record on first read)
const { plaintext } = await retrieveSecret(fullLink);
console.log(plaintext); // 'sk-live-abc123'
Example — split mode
// Agent A — split: URL in task context, key through a separate secure channel
const { url, key } = await createSecret('db-password', { split: true, expiry: 60 });
agentB.dispatch({ credentialUrl: url });
sideChannel.send(key); // key never shares a channel with the URL
// Agent B — reconstruct from separately delivered components
const { plaintext } = await retrieveSecret({ url, key });
Errors
| Error class | When | statusCode |
|---|---|---|
ValidationError | Link is malformed or missing key | — |
EncryptionError | Key import or AES-GCM decryption failed | — |
ApiError | Secret not found or expired | 404 |
ApiError | Secret already consumed (one-time) | 410 |
ApiError | Rate limit exceeded | 429 |
NetworkError | Connection failed or timed out (10 s) | — |
Webhook callback
Get notified when a secret is consumed — no polling.
Pass callbackUrl and callbackSecret at creation time.
When the secret is retrieved, Zephr POSTs a signed event to your URL.
const { fullLink } = await createSecret('db-password', {
expiry: 60,
hint: 'DB_PASSWORD_PROD',
callbackUrl: 'https://my-orchestrator.example.com/zephr-events',
callbackSecret: 'my-hmac-signing-secret',
apiKey: process.env.ZEPHR_API_KEY,
});
Idempotency
The SDK auto-generates an Idempotency-Key on every create — retries are safe
by default. If a request times out and the caller retries, the server returns the cached
response without creating a duplicate secret. Cache TTL: 24 hours.
End-to-end agent workflow
Agent A encrypts a credential and hands off the link. Agent B retrieves it exactly once. The plaintext is never in a prompt, a log, or shared memory — only in transit as ciphertext.
import { createSecret, retrieveSecret, ApiError } from 'zephr';
// ── Agent A ──────────────────────────────────────────────────
const { fullLink } = await createSecret(process.env.DB_PASSWORD, { expiry: 60 });
agentB.dispatch({ task: 'connect-db', credential: fullLink });
// ── Agent B ──────────────────────────────────────────────────
const { credential } = await receiveTask();
try {
const { plaintext: password } = await retrieveSecret(credential);
await db.connect({ password });
} catch (err) {
if (err instanceof ApiError && err.statusCode === 410)
throw new Error('Credential already used — request a fresh one.');
throw err;
}
Python SDK
One call handles key generation, encryption, upload, and link assembly — pass a string,
get back a shareable link. The right choice for applications, automation, and AI agent workflows.
Requires Python 3.10+. One dependency: cryptography, audited and widely used.
pip install zephr
create_secret()
| Parameter | Type | Required | Description |
|---|---|---|---|
secret |
str |
· | Plaintext to encrypt. Max 14,336 UTF-8 bytes. |
expiry |
5 | 15 | 30 | 60 | 1440 | 10080 | 43200 |
Minutes until expiration. Default: 60. Anonymous callers may use up to 60 (5, 15, 30, 60); any account unlocks 24 h, 7 d, and 30 d. |
|
split |
bool |
Return URL and key separately. Default: False |
|
hint |
str | None |
Plaintext label for routing and audit. 1-128 printable ASCII. Not encrypted. | |
api_key |
str | None |
Bearer token for authenticated requests. Default: None (anonymous) |
|
callback_url |
str | None |
HTTPS webhook URL — receives a signed event on consumption. Requires authentication and callback_secret. |
|
callback_secret |
str | None |
HMAC-SHA256 signing secret for the webhook. Required when callback_url is set. Max 256 chars. |
|
idempotency_key |
str | None |
Caller-provided idempotency key (1-64 alphanumeric + hyphens). Auto-generated if omitted. Cache TTL: 24 hours. |
Return value — standard mode
result = zephr.create_secret("secret")
result.mode # "standard"
result.full_link # "https://zephr.io/secret/Ht7kR2mNqP3wXvYz8aB4cD#v1.key..."
result.expires_at # "2026-02-21T12:00:00.000Z"
result.secret_id # "Ht7kR2mNqP3wXvYz8aB4cD"
result.url # None (standard mode)
result.key # None (standard mode)
Return value — split mode
result = zephr.create_secret("secret", split=True)
result.mode # "split"
result.url # "https://zephr.io/secret/Ht7kR2mNqP3wXvYz8aB4cD"
result.key # "v1.base64url-key..."
result.expires_at # "2026-02-21T12:00:00.000Z"
result.secret_id # "Ht7kR2mNqP3wXvYz8aB4cD"
result.full_link # None (split mode)
Exceptions
| Exception | Raised when |
|---|---|
zephr.ValidationError | Input is invalid (empty, too long, bad type), or server returned an unexpected response structure |
zephr.EncryptionError | AES-GCM encryption failed |
zephr.ApiError | Server error. Has .status_code and .code attributes. |
zephr.NetworkError | Connection failed or timed out (10s) |
Error handling
import zephr
try:
result = zephr.create_secret("my-api-key", expiry=60)
print(result.full_link)
except zephr.ValidationError as e:
print(f"Invalid input: {e}")
except zephr.ApiError as e:
print(f"Server error {e.status_code}: {e}")
except zephr.NetworkError as e:
print(f"Network error: {e}")
retrieve_secret()
Fetch and decrypt a one-time secret. The server marks the record consumed on first read —
a second call with the same link raises ApiError with status_code 410.
The decryption key never leaves the local environment; the server only ever holds ciphertext.
| Parameter | Type | Required | Description |
|---|---|---|---|
link |
str | dict |
· | Full link string, or {"url": ..., "key": ...} dict for split mode. |
api_key |
str | None |
Bearer token for authenticated requests. Default: None |
Returns
A RetrievalResult dataclass with attributes plaintext (str), hint (str or None), and purge_at (int or None, Unix seconds). The plaintext value is the decrypted secret exactly as it was passed to
create_secret(). Raises on any error condition; never returns None.
Exceptions
| Exception | When | status_code |
|---|---|---|
zephr.ValidationError | Link is malformed or missing key | — |
zephr.EncryptionError | Key import or AES-GCM decryption failed | — |
zephr.ApiError | Secret not found or expired | 404 |
zephr.ApiError | Secret already consumed (one-time) | 410 |
zephr.ApiError | Rate limit exceeded | 429 |
zephr.NetworkError | Connection failed or timed out (10 s) | — |
Agent workflows
The SDK is designed for agent-to-agent credential passing. Agent A encrypts the plaintext and hands off the link; Agent B opens it once to retrieve it. The plaintext is never in a prompt, a log, or shared memory — only in transit as ciphertext.
import zephr
# ── Agent A ──────────────────────────────────────────────────
db_password = os.environ["DB_PASSWORD"] # plaintext only in this process
result = zephr.create_secret(db_password, expiry=60)
agent_b.dispatch({"task": "connect-db", "credential": result.full_link})
# ── Agent B ──────────────────────────────────────────────────
task = agent_b.receive_task() # {"task": "connect-db", "credential": "https://zephr.io/...#v1.key..."}
try:
result = zephr.retrieve_secret(task["credential"])
db.connect(password=result.plaintext)
except zephr.ApiError as e:
if e.status_code == 410:
raise RuntimeError("Credential already used — request a fresh one.")
raise
Split mode
# Agent A — split: URL in task context, key through a separate channel
api_key = os.environ["THIRD_PARTY_API_KEY"]
result = zephr.create_secret(api_key, split=True, expiry=60)
agent_b.dispatch({"credential_url": result.url})
side_channel.send(result.key) # key never shares a channel with the URL
# Agent B — reconstruct from separately delivered components
task = agent_b.receive_task() # {"credential_url": "https://zephr.io/secret/..."}
received_key = side_channel.recv() # "v1.base64url-key..."
result = zephr.retrieve_secret({"url": task["credential_url"], "key": received_key})
plaintext = result["plaintext"]
Webhook callback
Get notified when a secret is consumed — no polling.
Pass callback_url and callback_secret at creation time.
result = zephr.create_secret("db-password",
expiry=60,
hint="DB_PASSWORD_PROD",
callback_url="https://my-orchestrator.example.com/zephr-events",
callback_secret="my-hmac-signing-secret",
api_key=os.environ.get("ZEPHR_API_KEY"),
)
Idempotency
The Python SDK auto-generates an Idempotency-Key header on every create —
retries are safe by default. If a request is replayed at the infrastructure level, the
server returns the cached response without creating a duplicate secret. Cache TTL: 24 hours.
GitHub Action
GitHub Actions jobs don't share memory or environment variables. Passing credentials
between jobs today requires GITHUB_OUTPUT, environment files, or
manual encoding. The Zephr action reduces this to two steps:
create in one job, retrieve in another. Encryption, log
masking, and one-time cleanup happen automatically.
uses: zephr-io/share-secret@v1
The action wraps the JavaScript SDK. Sensitive values — the input secret, the link,
the decrypted plaintext, and the API key — are registered with
core.setSecret() before any output is set, so they never appear in workflow
logs regardless of how the workflow is configured.
Cross-job secret sharing
jobs:
share:
runs-on: ubuntu-latest
outputs:
secret-link: ${{ steps.create.outputs.secret-link }}
steps:
- uses: zephr-io/share-secret@v1
id: create
with:
action: create
secret: ${{ secrets.DEPLOY_KEY }}
api-key: ${{ secrets.ZEPHR_API_KEY }}
expiry-minutes: '15'
deploy:
needs: share
runs-on: ubuntu-latest
steps:
- uses: zephr-io/share-secret@v1
id: retrieve
with:
action: retrieve
link: ${{ needs.share.outputs.secret-link }}
api-key: ${{ secrets.ZEPHR_API_KEY }}
- run: ./deploy.sh
env:
DEPLOY_KEY: ${{ steps.retrieve.outputs.plaintext }}
Inputs
| Input | Required | Default | Description |
|---|---|---|---|
action | · | create or retrieve | |
secret | Create | Plaintext secret to encrypt | |
link | Retrieve | Full Zephr link to retrieve | |
api-key | Zephr API key for authenticated requests | ||
hint | Plaintext label for routing and audit logs | ||
expiry-minutes | 60 | 5, 15, 30, 60, 1440, 10080, or 43200 | |
split | false | Return URL and key as separate outputs | |
callback-url | HTTPS webhook URL for consumption events | ||
callback-secret | HMAC-SHA256 signing secret for webhook | ||
idempotency-key | Idempotency key for safe retries |
Outputs
| Output | Mode | Description |
|---|---|---|
secret-link | Create (standard) | Full shareable link |
secret-url | Create (split) | Secret URL without key |
secret-key | Create (split) | Encryption key |
secret-id | Create | 22-char base64url identifier |
expires-at | Create | ISO 8601 expiration timestamp |
plaintext | Retrieve | Decrypted secret |
hint | Retrieve | Plaintext hint label |
Security
All sensitive values are masked via core.setSecret() and never appear in workflow logs:
inputs (secret, link, api-key, callback-secret)
and outputs (secret-link, secret-key, plaintext).
Secrets flow through action outputs only — never written to disk.
Add ZEPHR_API_KEY as a
repository secret
and pass it as the api-key input.
Full reference: zephr-io/share-secret on GitHub
REST API
Advanced integration. Your client must encrypt before calling these endpoints. Structural errors — wrong IV size, missing fields, bad encoding, ciphertext too short for the auth tag — return a 400 immediately. The one silent failure case: a correctly structured blob with wrong crypto parameters will upload successfully but fail at decryption. Use the CLI or Python SDK for all standard workflows.
For integrations in languages without an SDK — Go, Ruby, Java, or any HTTP client.
Two endpoints: deposit and retrieve.
Base URL: https://zephr.io/api
Authentication is optional. Anonymous requests work within the anonymous tier limits (3 creates/day, 1 h maximum expiry, 6 KB).
Pass an API key via Authorization: Bearer zeph_... to use your account's quota and limits.
Create a key at zephr.io/account.
A machine-readable OpenAPI 3.1 spec
is available at /openapi.json for LLM function calling, Postman import, and SDK generation.
POST/api/secrets
Deposit an encrypted secret. Returns an ID and expiration timestamp.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
encrypted_blob |
string |
· | base64url-encoded blob — see format below |
expiry |
5 | 15 | 30 | 60 | 1440 | 10080 | 43200 |
· | Minutes until expiration. Anonymous callers may use up to 60 (5, 15, 30, 60); any account unlocks 24 h, 7 d, and 30 d. |
split_url_mode |
boolean |
If true, recipient enters the key manually. Default: false |
|
hint |
string |
Plaintext label for routing and audit logs. Not encrypted. 1-128 printable ASCII characters. | |
callback_url |
string |
HTTPS webhook URL. When set, Zephr POSTs a signed JSON event on consumption or expiry. Requires authentication (API key or session) and callback_secret. |
|
callback_secret |
string |
HMAC-SHA256 signing secret for the webhook. Required when callback_url is set. Max 256 characters. Stored alongside the secret record (encrypted at rest) until consumption, then destroyed. |
Headers
| Header | Type | Required | Description |
|---|---|---|---|
Idempotency-Key |
string |
Caller-generated key (typically a UUID, max 64 chars). Duplicate requests within 24h return the cached response. The JS SDK auto-generates this on every create. |
Blob format
Construct a JSON object, then base64url-encode the entire object to produce
encrypted_blob. All binary values are base64url-encoded individually first.
{
"iv": "<96-bit random IV, base64url>",
"ciphertext": "<AES-GCM-256 output with 128-bit auth tag appended, base64url>"
}
// → base64url-encode this entire JSON string = encrypted_blob
- Algorithm: AES-GCM-256. Key: 256 bits, random. IV: 96 bits, random, unique per encryption.
- Auth tag: 128 bits, appended to the ciphertext bytes before encoding — not a separate field.
- Minimum decoded ciphertext: 17 bytes (1 byte plaintext + 16 byte auth tag) — enforced server-side with a 400.
- Encoding: base64url throughout (RFC 4648 §5) — standard base64 is rejected.
- Link key fragment format:
v1.<raw 256-bit key, base64url>
Response — 201 Created
{
"id": "Ht7kR2mNqP3wXvYz8aB4cD",
"expires_at": "2026-02-21T12:00:00.000Z"
}
GET/api/secrets/:id
Retrieve and consume a secret. One-time only — the record is marked consumed atomically and returns 410 on any subsequent request.
Response — 200 OK
{
"encrypted_blob": "eyJpdiI6IkF...",
"purge_at": 1771938000
}
purge_at: Unix timestamp in seconds after which the physical record is deleted from storage (includes a server-side grace period beyond expires_at). Note this differs from expires_at on the create response, which is an ISO 8601 string. Clients may ignore this field.
If the creator set a hint, it is included in the response as a top-level string field.
Response — 404 Not Found
{
"error": {
"code": "SECRET_NOT_FOUND",
"message": "Secret not found"
}
}
Response — 410 Gone
{
"error": {
"code": "SECRET_ALREADY_CONSUMED",
"message": "Secret has already been consumed"
}
}
// or, if the secret expired before retrieval:
{
"error": {
"code": "SECRET_EXPIRED",
"message": "Secret has expired"
}
}
Webhook callback payload
If callback_url was set at creation time, Zephr POSTs a signed event when the secret is consumed.
Verify the X-Zephr-Signature header — it contains the HMAC-SHA256 hex digest of the JSON body, signed with your callback_secret.
// POST to your callback_url
// Header: X-Zephr-Signature: <HMAC-SHA256 hex>
{
"event": "secret.consumed", // or "secret.expired"
"event_id": "550e8400-e29b-41d4-a716-446655440000", // UUID for deduplication
"secret_id": "Ht7kR2mNqP3wXvYz8aB4cD",
"occurred_at": "2026-03-22T14:32:00.000Z",
"hint": "STRIPE_KEY_PROD" // present only if hint was set
}
Verifying the signature — Node.js
import crypto from 'node:crypto';
function verifyZephrSignature(rawBody, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(String(signature));
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
// In your Express route:
const sig = req.headers['x-zephr-signature'];
const valid = verifyZephrSignature(rawBody, sig, 'my-callback-secret');
Verifying the signature — Python
import hashlib, hmac
def verify_zephr_signature(raw_body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
# In your Flask route:
sig = request.headers.get('X-Zephr-Signature')
valid = verify_zephr_signature(request.get_data(), sig, 'my-callback-secret')
Always verify the signature before trusting the payload. Use timing-safe comparison
(crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python)
to prevent timing attacks. Full runnable receiver examples:
examples/webhook-receiver.
Fire-and-forget in v1 — no retries. Failed deliveries are logged server-side but do not affect the retrieve response. 5-second timeout. Redirects blocked.
Account-level webhook defaults
Instead of passing callback_url and callback_secret on every create request,
you can configure account-level defaults. These are applied automatically to all new secrets unless
overridden per-secret.
Set defaults via PUT /api/account/webhook (session auth required):
{
"webhook_url": "https://my-orchestrator.example.com/zephr-events",
"webhook_secret": "my-hmac-signing-secret"
}
Response: { "webhook_url": "https://...", "webhook_secret_set": true }
To clear defaults, send an empty body or null values. The current configuration is returned in
GET /api/auth/me as webhookUrl and webhookSecretSet.
Errors
All non-2xx responses follow the same JSON envelope. Use code for
programmatic handling — it's stable across releases. The message field
is human-readable and intended for debugging, not parsing.
Shape: {"error": {"code": "…", "message": "…"}}
| Code | HTTP | Meaning |
|---|---|---|
MISSING_* / INVALID_* |
400 | Request is missing required fields or contains invalid values — check the specific code value |
INVALID_API_KEY |
401 | The provided API key does not exist or has been revoked |
SECRET_NOT_FOUND |
404 | Secret ID does not exist |
SECRET_ALREADY_CONSUMED |
410 | Secret has already been retrieved |
SECRET_EXPIRED |
410 | Secret passed its expiration time before being retrieved |
PAYLOAD_TOO_LARGE |
413 | Encrypted blob exceeds the size limit for the tier (6 KB anonymous, 20 KB Free) |
UPGRADE_REQUIRED |
403 | The request is not permitted for the caller's tier — e.g. any expiry other than 60 min without an account, or a webhook callback_url without an account |
ANON_RATE_LIMIT_EXCEEDED |
429 | Anonymous daily limit reached (3/day) — create a free account for more |
MONTHLY_LIMIT_EXCEEDED |
429 | Monthly create limit reached for this account (500 on the free tier) — resets at the start of the next calendar month |
INTERNAL_ERROR |
500 | Unexpected server error |