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.
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.
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.
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
} # 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.
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:
mytunnel http 3000 --token-env TOKEN
# → https://abc123.21tunnel.com 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.
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.)
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.
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.
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".
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.
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).
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).
{
"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.
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.
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.
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.