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://login.21tunnel.com/api"
export MYTUNNEL_API_KEY="mtk_xxx_your_capability_token"   # agent transport
export JWT="eyJhbG..."                                     # dashboard REST API

$API is the hosted REST base URL. mytunnel reads MYTUNNEL_API_KEY automatically — no flag needed. The key and $JWT come from the dashboard. On a laptop, mytunnel login is simpler; on a server, use a key file (Permanent tunnels). The full rules, including precedence and master keys, are on Access control.

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. Rotating the key carries the cap over unchanged.

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,
      "bandwidthLimitMbps": 100,
      "tunnelQuota": 10,
      "monthlyBudgetUsdCents": 5000
    }'

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

{
  "apiKey": "mtk_AAAB...full_value_here",
  "nonce": "9f2c4a7e1b3d5f60...",
  "issuedAt": 1786636800,
  "expiresAt": 1789228800,
  "tunnelQuota": 10,
  "bandwidthLimitMbps": 100,
  "allowedSubdomains": ["*"],
  "wireBytes": "AAABZm9vYmFy...",
  "wireBytesLen": 312,
  "version": 1, "subject": "...", "signature": "..."
}

Other body fields: allowedSubdomains (default ["*"]). ttlHours defaults to 24 and is capped at 2160 (90 days) — a longer request is cut to the maximum, not rejected, so read expiresAt. The budget isn't echoed back. Hand apiKey to the agent as MYTUNNEL_API_KEY.

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          # key from MYTUNNEL_API_KEY
# → https://abc123.21tunnel.com

TCP with a reserved public port

mytunnel tcp 5432 --public-port 35432
# → 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 Permanent tunnels

For a tunnel that should outlive your terminal: declare it in mytunnel.toml, install it as a systemd service, and let a timer rotate its key. The agent reconnects by itself after any drop and keeps the same URL.

Declare the tunnels

# mytunnel.toml — one [tunnels.<name>] table per tunnel
[tunnels.tool]
type = "http"            # http | tcp | ssh | udp
port = 8080
subdomain = "tool"       # optional, http only

[tunnels.db]
type = "tcp"
port = 5432
# public_port = 30432    # optional, tcp/udp only

mytunnel start runs every tunnel in the file. It looks for --tunnels-config FILE, then ./mytunnel.toml, then ~/.config/mytunnel/tunnels.toml. Top-level server and token_file are optional. An unknown type or key is rejected up front, so a typo fails at startup instead of being skipped.

Install it as a service (Linux)

export MYTUNNEL_API_KEY="mtk_..."      # a project key
sudo -E mytunnel service install --tunnels-config ./mytunnel.toml

This copies the tunnels file to /etc/21tunnel/<name>.toml, writes the current key (from MYTUNNEL_API_KEY, --token-file, --token-env or mytunnel login) to /etc/21tunnel/<name>.key, readable by root only, and enables and starts 21tunnel-<name>.service. --name defaults to agent. The unit restarts forever (Restart=always, no start limit) and runs sandboxed: no capabilities, read-only filesystem.

journalctl -u 21tunnel-agent -f                # logs
sudo mytunnel service uninstall --name agent    # stop; remove unit, tunnels file, key

Install mytunnel system-wide first — the install one-liner run as root puts it in /usr/local/bin (curl -fsSL https://login.21tunnel.com/install.sh | sudo sh). The service refuses a binary in a home directory. It warns if the key expires within 14 days.

Reconnects by itself

Every tunnel command — http, tcp, udp, ssh, grpc, start, wg up — reconnects forever after a drop (server restart, server IP change, network blip), with exponential backoff and jitter from about 1 s up to 30 s. It re-resolves agent.21tunnel.com on every attempt and asks for the same subdomain or public port again, so the URL stays the same. A dead server is noticed through unanswered heartbeats (sent at most every 30 s).

It gives up, exiting non-zero, only on things a person has to fix: a key rejected on the first connect, an invalid subdomain, or a client too old for the server. A key rejected after it was already connected (revoked, expired, rotated) is retried every 30 s, re-reading a key file each time. Ctrl+C and SIGTERM stop it cleanly. To stop on the first drop instead, set auto_reconnect = false in a CLI config file passed with --config FILE (the file must also set server = "agent.21tunnel.com:7443" and region = "us-east"). The old reconnect_interval_secs setting is ignored; the backoff replaces it.

Rotate the key without a restart

mytunnel projects keys rotate <project> --key-file /etc/21tunnel/agent.key --grace 24

This mints an equivalent key — same project, quota, bandwidth, subdomains, budget and lifetime — and keeps the old one working for --grace hours (default 1; 0 revokes it at once). The new key is written back to the file atomically (mode 0600). The agent reading that file switches to it on its next reconnect, at the latest when the grace window ends (the server ends sessions on the old key within about 30 s of its expiry). That costs one reconnect of about a second, and no restart.

--if-expires-within HOURS makes it a no-op until the key has that many hours or fewer left, so a daily timer can call it unconditionally. Rotating needs a master key — the agent key can't rotate itself — so the agent host needs one. Keep it in its own root-only env file, separate from the agent key: /etc/21tunnel/rotate.env, mode 0600, containing MYTUNNEL_API_KEY=mtk_master_….

# /etc/systemd/system/21tunnel-agent-rotate.service
[Unit]
Description=Rotate the 21tunnel agent key before it expires
[Service]
Type=oneshot
# rotate.env holds MYTUNNEL_API_KEY=mtk_master_...
EnvironmentFile=/etc/21tunnel/rotate.env
ExecStart=/usr/local/bin/mytunnel projects keys rotate <project> --key-file /etc/21tunnel/agent.key --if-expires-within 720 --grace 24

# /etc/systemd/system/21tunnel-agent-rotate.timer
[Unit]
Description=Daily 21tunnel agent key rotation check
[Timer]
OnCalendar=daily
RandomizedDelaySec=1h
Persistent=true
[Install]
WantedBy=timers.target
sudo systemctl enable --now 21tunnel-agent-rotate.timer

The master key expires too (default 90 days, at most 1 year): replace it in rotate.env before it does. Over REST the same call is POST /api/projects/<project>/tokens/<nonce>/rotate with {"graceHours": 24} (0–720). An expired but unrevoked key can still be rotated; master keys can't be rotated this way.

Stable names

Use a reserved subdomain (subdomain = "tool"). With a key from a project other than default, the server serves it as tool-<project>.21tunnel.com. For a name of your own, bind a custom domain to the tunnel in the dashboard. For an internal tool, a Google sign-in gate limited to your email domain keeps it to your team, on the *.21tunnel.com name and on any custom domain bound to the tunnel alike.

5 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.

6 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).

7 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.

8 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   /projects/:slug/tokensMint a project key. Master key or JWT.
DELETE /projects/:slug/tokens/:nonceRevoke a project key; live tunnels cut off at once. Master key or JWT.
POST   /projects/:slug/tokens/:nonce/rotateRotate with a grace window (graceHours, 0–720). Master key or 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