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