Skip to main content

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:

  1. CLI creates request with an ephemeral public key
  2. Server stores request in Redis (5-minute TTL)
  3. User approves in browser and selects keys
  4. Server encrypts selected key values to the CLI's public key
  5. CLI polls and retrieves the encrypted payload
  6. 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:

FieldTypeRequiredDescription
client_pubkeystringYesBase64-encoded X25519 public key (encoded bytes)
bundle_idstringNoSuggested bundle (UUID or name) to pre-select on the approval page
template_keysarrayNoSpecific key names to request (UI can pre-select these)
formatstringNoOutput format: sh, fish, .env (default: sh)

Response (201 Created):

{
"id": "abc123xyz",
"verification_uri_complete": "/approve/abc123xyz",
"interval": 3,
"expires_in": 300
}

Response Fields:

FieldTypeDescription
idstringRequest ID (used for polling and approval)
verification_uri_completestringApproval page path to open in a browser (append to BFF host)
intervalintegerSuggested polling interval in seconds (default: 3)
expires_inintegerTTL 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:

ParameterTypeRequiredDescription
intervalintegerNoPolling 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:

CodeDescription
404Request not found or expired (TTL passed)
429Too 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 ready with ciphertext (single-use). If you miss it, create a new request.
  • Respect the interval to 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:

CodeDescription
404Request 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:

CodeDescription
401/403Missing or invalid authentication
404Request 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:

CodeDescription
401/403Missing or invalid authentication
404Request 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 reveal flag 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:

CodeDescription
401/403Missing or invalid authentication
404Request or bundle not found
400Invalid keys or bad request (e.g. empty include_keys)
413Payload 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 /wait endpoint.

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