DOCS · API FOR AGENTS

Built for agents.

Budget-capped keys an agent can't raise on its own. Structured errors with a next_action field so a model branches without re-prompting the human. Idempotent retries. Webhook receivers with no public server. The whole API is designed to be driven headlessly and safely.

1 Setup

Sign up at login.21tunnel.com, mint a capability token from the dashboard (shown once). That token authorises the agent transport. The dashboard REST API uses your session JWT.

export API="https://api.21tunnel.com"
export TOKEN="mtk_xxx_your_capability_token"   # agent transport
export JWT="eyJhbG..."                          # dashboard REST API

Self-hosted? Swap $API for your endpoint and set QNT_PUBLIC_API_BASE on the server so generated webhook URLs point at the right host.

2 Budget-capped keys

The cap is the core trust mechanic: a hard ceiling on spend before human approval. The agent cannot raise its own cap — only an org owner can, via PUT /tokens/:id/budget or by rotating the key.

Mint a key with a cap

curl -X POST "$API/tokens" \
    -H "Authorization: Bearer $JWT" \
    -H "Content-Type: application/json" \
    -d '{
      "name": "claude-agent-prod",
      "ttlHours": 720,
      "bandwidth": 100,
      "tunnelQuota": 10,
      "monthlyBudgetUsdCents": 5000
    }'

The response is the only place the full key value appears:

{
  "id": "abc-123",
  "name": "claude-agent-prod",
  "apiKey": "mtk_xxx_full_value_here",
  "monthlyBudgetUsdCents": 5000
}

Adjust the cap (owner only)

# Raise / lower
curl -X PUT "$API/tokens/$NONCE/budget" \
    -H "Authorization: Bearer $JWT" \
    -d '{ "monthlyBudgetUsdCents": 1000 }'

# Uncap
curl -X PUT "$API/tokens/$NONCE/budget" \
    -H "Authorization: Bearer $JWT" \
    -d '{ "monthlyBudgetUsdCents": null }'

# Freeze all spend (incident response)
curl -X PUT "$API/tokens/$NONCE/budget" \
    -H "Authorization: Bearer $JWT" \
    -d '{ "monthlyBudgetUsdCents": 0 }'

$NONCE is the first 16 hex chars of the token's nonce, visible in the GET /tokens list. The budget is charged on the agent transport at tunnel-registration time — an over-budget agent's mytunnel register is rejected with a BUDGET_EXCEEDED protocol reason, and the agent should surface that to its operator rather than retry.

3 Open tunnels

Six protocols, one binary — HTTP, TCP (incl. reserved port), UDP, gRPC, SSH, and HTTP/3 at the edge. Full walkthrough with per-protocol examples lives on the Tunnel types page. The agent-side essentials:

HTTP (subdomain-routed)

mytunnel http 3000 --token-env TOKEN
# → https://abc123.21tunnel.com

TCP with a reserved public port

mytunnel tcp 5432 --public-port 35432 --token-env TOKEN
# → tcp://abc.21tunnel.com:35432

Out-of-range or already-held ports are rejected with a PortUnavailable protocol reason. Retry without --public-port to let the server pick a free one.

UDP, gRPC, SSH

mytunnel udp 19132 --public-port 19132   # DNS, game servers, WireGuard
mytunnel grpc 50051                       # grpcurl-ready output
mytunnel ssh                              # prints ssh -p line

All shipped — see Tunnel types for protocol-specific notes. (Note: the older repo quickstart lists UDP/gRPC/HTTP-3 as "roadmap" — that's stale; they're live.)

4 Webhook receivers

Receive third-party webhooks without running a public server. Provision a receiver, paste its URL into the vendor's config, poll validated events back. Validators wired today: github, slack, generic_hmac_sha256.

Provision (GitHub)

curl -X POST "$API/webhook-receivers" \
    -H "Authorization: Bearer $JWT" \
    -H "Content-Type: application/json" \
    -d '{
      "validator": "github",
      "secret": "whsec_from_github_webhook_config",
      "name": "PR events for my-bot"
    }'

Paste the returned url into the repo's webhook settings. GitHub starts firing signed POSTs.

Generic HMAC-SHA256 (Linear, Vercel, GitLab, your app)

curl -X POST "$API/webhook-receivers" \
    -H "Authorization: Bearer $JWT" \
    -d '{
      "validator": "generic_hmac_sha256",
      "secret": "<vendor-secret>",
      "signature_header": "x-my-vendor-signature",
      "signature_encoding": "hex",
      "name": "Linear issue events"
    }'

signature_encoding is "hex" or "base64".

Poll validated events

curl "$API/webhook-receivers/$ID/events?limit=50" \
    -H "Authorization: Bearer $JWT"

Each event carries a signature_valid flag — false means the signature didn't verify; we store it so you can alert, but don't trust the body. Use the received_at of the last event you saw as a cursor via ?since=<timestamp>.

dodo as a validator value is accepted by the schema but not yet wired — Dodo billing events flow through the dedicated /webhooks/dodo endpoint, not the tenant receiver primitive.

5 Idempotent retries

Every mutating endpoint accepts Idempotency-Key: <any-uuid>. A repeat within 24 hours returns the identical original response plus X-Idempotent-Replay: true. Errors are NOT cached — fix the bug, retry the same key, get the corrected response.

IDEM=$(uuidgen)

# First call
curl -X POST "$API/tunnels" \
    -H "Authorization: Bearer $JWT" \
    -H "Idempotency-Key: $IDEM" \
    -d '{"subdomain":"my-app","protocol":"http","localPort":3000}'

# Network blip → safe to repeat with the SAME key
curl -X POST "$API/tunnels" \
    -H "Authorization: Bearer $JWT" \
    -H "Idempotency-Key: $IDEM" \
    -d '{"subdomain":"my-app","protocol":"http","localPort":3000}'
# → identical response + header X-Idempotent-Replay: true

The cache key is a composite hash of api_key_id · method · path · body · header, so a leaked key from one tenant can't replay another tenant's request. Reusing the same key with a different body is a conflict (your retry logic has a bug).

6 Structured errors

Agent-grade endpoints (POST /tunnels, /webhook-receivers/*) return a structured envelope with a machine-readable error code and a next_action directive. Branch on error + next_action, never on message (human prose, may change).

Envelope shape

{
  "error": "TTL_TOO_LONG",
  "message": "ttl_seconds exceeds plan max of 86400 seconds for plan 'free'",
  "next_action": "FIX_REQUEST_AND_RETRY",
  "request_id": "9a3f-..."
}

Rate-limit and internal-error responses additionally carry a retry_after_ms hint so an agent can self-throttle without polling.

Verified codes + directives

SUBDOMAIN_TAKENCHOOSE_DIFFERENT_SUBDOMAIN — pick another or omit to auto-assign.
TTL_TOO_LONGFIX_REQUEST_AND_RETRY — requested TTL exceeds plan max; lower it.
VALIDATION_FAILEDFIX_REQUEST_AND_RETRY — body shape wrong; the message has specifics.
NOT_FOUNDNO_ACTION_POSSIBLE — resource gone; don't retry.
(rate limit)RETRY_WITH_BACKOFF — sleep retry_after_ms, then retry.
internalRETRY_WITH_BACKOFF — retry once; if it persists, file an issue with request_id.

Older endpoints still return the legacy shape { "error": "snake_case", "message": "…" } (e.g. missing_bearer, upgrade_required) without next_action. These migrate to the structured envelope as they're touched. Always handle both: presence of next_action tells you which shape you got.

Budget enforcement is on the transport, not REST

When an agent's spend cap is hit, the rejection happens at tunnel-registration time over the agent transport (the mytunnel register fails with a BUDGET_EXCEEDED protocol reason), not as an HTTP error from POST /tunnels. An agent that sees this should stop its loop and request human approval — the cap won't move until an owner raises it.

7 Endpoint reference

The high-frequency agent endpoints. Full machine-readable spec at login.21tunnel.com/api/openapi.yaml — or browse it on the API reference page.

GET    /healthLiveness. No auth.
GET    /readyReadiness. No auth.
POST   /tokensMint a capability token. JWT.
GET    /tokensList tokens (with nonces). JWT.
PUT    /tokens/:id/budgetSet / clear the spend cap. JWT, owner.
POST   /tokens/:id/rotateRotate a key. JWT.
DELETE /tokens/:idRevoke. JWT.
POST   /tunnelsReserve a subdomain tunnel. JWT. (Structured errors.)
GET    /tunnelsList tunnels. JWT.
PUT    /tunnels/:id/policySet traffic policy. JWT.
POST   /webhook-receiversProvision a receiver. JWT.
GET    /webhook-receivers/:id/eventsPoll validated events. JWT.
POST   /webhooks/:receiver_idVendor ingestion. No JWT — vendor signature is auth.
GET    /activityOrg audit log. JWT.

Next