Requests API
summary: Request/approval endpoints used by the CLI and web approval UI
title: Requests API audience: Developer difficulty: Advanced estimated_read_time: 8 min prerequisites:
- Understanding of device approval flow
- CLI usage knowledge related_pages:
- ../device-approval.md
- ../cli-reference/envcat-get.md
Approval request endpoints for CLI-to-browser secret delivery.
Overview
The Requests API implements the device-approval pattern used by the CLI:
- CLI creates request with an ephemeral public key
- Server stores request in Redis (5-minute TTL)
- User approves in browser and selects keys
- Server encrypts selected key values to the CLI's public key
- CLI polls and retrieves the encrypted payload
- CLI decrypts locally and emits exports or writes a .env
Base URL: https://env.cat/api/v1/requests (BFF). For local development use the Next.js BFF at http://localhost:8888/api/v1/requests.
Authentication: Mixed (some endpoints are public, the approval endpoints require authentication)
Endpoints
Create Request (Public)
CLI creates an approval request. The request contains the CLI's ephemeral public key and optional hints (bundle, template keys, output format).
Endpoint:
POST /api/v1/requests
Authorization: None (public)
Request Body:
{
"client_pubkey": "base64_encoded_x25519_public_key",
"bundle_id": "bundle-uuid-or-name",
"template_keys": ["DATABASE_URL", "API_KEY"],
"format": "sh"
}
Parameters:
| Field | Type | Required | Description |
|---|---|---|---|
client_pubkey | string | Yes | Base64-encoded X25519 public key (encoded bytes) |
bundle_id | string | No | Suggested bundle (UUID or name) to pre-select on the approval page |
template_keys | array | No | Specific key names to request (UI can pre-select these) |
format | string | No | Output format: sh, fish, .env (default: sh) |
Response (201 Created):
{
"id": "abc123xyz",
"verification_uri_complete": "/approve/abc123xyz",
"interval": 3,
"expires_in": 300
}
Response Fields:
| Field | Type | Description |
|---|---|---|
id | string | Request ID (used for polling and approval) |
verification_uri_complete | string | Approval page path to open in a browser (append to BFF host) |
interval | integer | Suggested polling interval in seconds (default: 3) |
expires_in | integer | TTL in seconds (300 = 5 minutes) |
Examples:
# Production (public BFF)
curl -s -X POST https://env.cat/api/v1/requests \
-H "Content-Type: application/json" \
-d '{
"client_pubkey": "BASE64_PUBLIC_KEY_HERE",
"bundle_id": "dev/api",
"format": "sh"
}' | jq
# Local development (Next.js BFF at localhost:8888)
curl -s -X POST http://localhost:8888/api/v1/requests \
-H "Content-Type: application/json" \
-d '{
"client_pubkey": "BASE64_PUBLIC_KEY_HERE",
"bundle_id": "dev/api",
"format": "sh"
}' | jq
# Capture the request id for scripting
id=$(curl -s http://localhost:8888/api/v1/requests -H "Content-Type: application/json" -d '{"client_pubkey":"BASE64_PUBLIC_KEY_HERE"}' | jq -r '.id')
Notes:
- The CLI should display the verification URI (BFF host +
verification_uri_complete) and an ASCII QR code for the user to open on another device. - Keep the private key in-memory only; never persist on disk.
Wait for Approval (Public)
The CLI polls this endpoint to wait for the server to mark the request ready and return the encrypted payload. The endpoint implements a short long-poll (up to 30s) to reduce rapid polling.
Endpoint:
GET /api/v1/requests/:id/wait
Authorization: None (public)
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
interval | integer | No | Polling interval hint (seconds). Respect this to avoid rate limits. |
Responses:
- Pending (200):
{ "status": "pending" }
- Ready (200):
{
"status": "ready",
"ciphertext_base64": "base64_encoded_sealed_box"
}
Errors:
| Code | Description |
|---|---|
| 404 | Request not found or expired (TTL passed) |
| 429 | Too many requests (slow down your polling). The server includes a Retry-After header (seconds) when this is returned — increase your polling interval accordingly to avoid further 429 responses. |
Examples:
# Single poll
curl -s "http://localhost:8888/api/v1/requests/$id/wait?interval=3" | jq
# Looping poll (simple bash):
while true; do
resp=$(curl -s "http://localhost:8888/api/v1/requests/$id/wait?interval=3")
status=$(echo "$resp" | jq -r '.status')
if [ "$status" = "ready" ]; then
echo "$resp" | jq -r '.ciphertext_base64' > ciphertext.b64
break
fi
sleep 3
done
# Decode the ciphertext to a file for local decryption
base64 -d ciphertext.b64 > ciphertext.bin
Quick Python example to decrypt (PyNaCl) — for debugging only:
# Requires: pip install pynacl
import base64, json
from nacl.public import PrivateKey, SealedBox
sk_b64 = 'BASE64_PRIVATE_KEY' # CLI private key saved during the request
sk = PrivateKey(base64.b64decode(sk_b64))
with open('ciphertext.bin','rb') as f:
ciphertext = f.read()
clear = SealedBox(sk).decrypt(ciphertext)
print(clear.decode()) # JSON map of key -> value
Notes:
- The server deletes the request after returning
readywith ciphertext (single-use). If you miss it, create a new request. - Respect the
intervalto avoid triggering rate-limiting.
Get Request Context (Public)
Provides minimal request information used by the approval page before the user authenticates.
Endpoint:
GET /api/v1/requests/:id/context
Authorization: None (public)
Response (200 OK):
{
"cli_pubkey": "base64_encoded_public_key",
"suggested_bundle_id": "dev/api",
"template_keys": ["DATABASE_URL", "API_KEY"],
"requester_meta": {}
}
Errors:
| Code | Description |
|---|---|
| 404 | Request not found or expired |
Example:
curl -s http://localhost:8888/api/v1/requests/$id/context | jq
List Bundles for Request (Authenticated)
List the authenticated user's bundles. The UI uses this to present bundle choices to the approver.
Endpoint:
GET /api/v1/requests/:id/bundles
Authorization: Session cookie (authenticated)
Response (200 OK):
[
{
"id": "bundle-uuid-1",
"name": "dev/api",
"description": "Development API secrets"
},
{
"id": "bundle-uuid-2",
"name": "prod/database",
"description": "Production database credentials"
}
]
Errors:
| Code | Description |
|---|---|
| 401/403 | Missing or invalid authentication |
| 404 | Request not found or expired |
Example:
curl -s "http://localhost:8888/api/v1/requests/$id/bundles" \
-H "Cookie: __session=your_clerk_session" | jq
Notes:
- The BFF forwards session cookies to the Flask API so authenticated UI requests work from the browser.
Get Bundle for Request (Authenticated)
Returns bundle metadata and a list of attached keys. Values are masked in the approval UI.
Endpoint:
GET /api/v1/requests/:id/bundles/:bundle_id
Authorization: Session cookie (authenticated)
Response (200 OK):
{
"id": "bundle-uuid",
"name": "dev/api",
"description": "Development API secrets",
"items": [
{ "key": "DATABASE_URL", "value": "*****", "is_secret": true },
{ "key": "API_KEY", "value": "*****", "is_secret": true },
{ "key": "PORT", "value": "*****", "is_secret": false }
]
}
Errors:
| Code | Description |
|---|---|
| 401/403 | Missing or invalid authentication |
| 404 | Request or bundle not found |
Example:
curl -s "http://localhost:8888/api/v1/requests/$id/bundles/bundle-uuid" \
-H "Cookie: __session=your_clerk_session" | jq
Notes:
- Values are masked in the UI to avoid accidental exposure. In development mode an admin-only
revealflag may be available on other endpoints.
Encrypt from Bundle (Authenticated)
Operator action: user approves selected keys. The server loads the selected keys, decrypts them in memory, encrypts to the CLI's public key (sealed box), stores ciphertext in Redis, and marks the request ready.
Endpoint:
POST /api/v1/requests/:id/encrypt-from-bundle
Authorization: Session cookie (authenticated)
Request Body:
{
"bundle_id": "bundle-uuid",
"include_keys": ["DATABASE_URL", "API_KEY"]
}
Response (200 OK):
{ "ok": true }
Errors:
| Code | Description |
|---|---|
| 401/403 | Missing or invalid authentication |
| 404 | Request or bundle not found |
| 400 | Invalid keys or bad request (e.g. empty include_keys) |
| 413 | Payload too large (rare; indicates payload exceeds configured size) |
Examples:
# Approve selected keys (from browser action)
curl -s -X POST "http://localhost:8888/api/v1/requests/$id/encrypt-from-bundle" \
-H "Content-Type: application/json" \
-H "Cookie: __session=your_clerk_session" \
-d '{"bundle_id":"bundle-uuid","include_keys":["DATABASE_URL","API_KEY"]}' | jq
# Developer/testing: push ciphertext directly (dev-only endpoint)
curl -s -X PUT "http://localhost:8888/api/v1/requests/$id/payload" \
-H "Content-Type: application/json" \
-d '{"ciphertext_base64":"BASE64_CIPHERTEXT_FOR_TESTING"}' | jq
Notes:
- The server enforces checks: keys must exist and be attached to the chosen bundle.
- The encryption operation happens server-side to avoid exposing plaintext to the browser.
- The ciphertext stored in Redis is base64-encoded and retrieved by the CLI via the
/waitendpoint.
Flow Diagram
CLI Server Browser
| | |
|-- POST /requests ----------->| |
| (with public key) | |
| | |
|<-- 201 Created --------------| |
| {id, approval_url} | |
| | |
| (display URL + QR code) | |
| | |
| |<-- GET /approve/:id -----------|
| | (user opens approval page) |
| | |
| |--- 200 OK ------------------>|
| | (show bundle selector) |
| | |
|-- GET /wait ---------------->| |
| (poll every 3s) | |
| | |
|<-- 200 OK -------------------| |
| {status: "pending"} | |
| | |
| |<-- POST /encrypt-from-bundle --|
| | {bundle_id, keys} |
| | |
| | [decrypt values in memory] |
| | [encrypt to CLI pubkey] |
| | [store base64 ciphertext] |
| | [set status: ready] |
| | |
| |--- 200 OK ------------------->|
| | {ok: true} |
| | |
|-- GET /wait ---------------->| |
| | |
|<-- 200 OK -------------------| |
| {status: "ready", | |
| ciphertext: "..."} | |
| | |
| [decrypt with private key] | |
| [output shell exports] | |
| | |
Security Model
End-to-End Encryption
Keypair generation (CLI):
- X25519/Ed25519 ephemeral keypair generated per request
- Private key never leaves CLI process
- Public key sent to server
Encryption (Server):
- NaCl sealed box (X25519 + XSalsa20-Poly1305)
- Server encrypts to CLI's public key
- Only CLI can decrypt (has the private key)
Storage (Redis):
- Only ciphertext stored (no plaintext)
- TTL: 5 minutes
- Single-use (deleted after retrieval)
Threat Model
What we protect against:
- ✅ Server compromise (secrets encrypted)
- ✅ Network eavesdropping (E2E encryption)
- ✅ Database leaks (ephemeral in Redis)
- ✅ Request replay (single-use, TTL)
What we don't protect against:
- ❌ Compromised CLI host (malware can read decrypted secrets)
- ❌ Social engineering (attacker tricks you into approving)
- ❌ Shoulder surfing (approval UI shows bundle names)
See Also
- Bundles API - Bundle management
- Keys API - Key management
- Authentication - Auth flow
- Approval Flow - User guide
- CLI Reference - CLI usage