Every dashboard action is a plain HTTPS call. Base URL
https://login.21tunnel.com/api, JSON in and out, one bearer token. Copy a
curl, swap your token, ship. The full machine-readable
spec lives at /openapi.yaml.
The REST API backs the 21tunnel dashboard. Anything you can click,
you can curl. No SDK required.
https://login.21tunnel.com/api (self-hosted:
http://127.0.0.1:9090). api.21tunnel.com
is an equivalent alias.application/json. IDs are
UUIDs; timestamps are RFC 3339 UTC./health, /ready and
/auth/* needs an Authorization: Bearer <jwt>
header.{ "error": "code", "message": "..." } — branch on the
machine-readable error, never the prose./auth/* is 5-burst + 1 request / 12s per
source IP. Other routes are unlimited at the app layer.curl -s https://login.21tunnel.com/api/health { "status": "healthy", "version": "0.3.0" } GET /ready additionally waits for the DB,
JWT keys and secret manager (200 when fully warm, else 503).
export API="https://login.21tunnel.com/api"
export JWT="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." # from POST /auth/login Every example below uses these two variables.
Exchange email + password for a 15-minute access JWT. A rotating,
HttpOnly qnt_refresh cookie renews it. Machine agents use
capability tokens instead — see Tokens.
curl -X POST "$API/auth/login" \
-H "Content-Type: application/json" \
-c cookies.txt \
-d '{ "email": "you@example.com", "password": "your-password" }' {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": { "id": "6c81eee3-...", "email": "you@example.com" },
"active_org": { "id": "406ba3e4-...", "name": "Acme", "slug": "acme",
"role": "owner", "approval_status": "approved" },
"is_superadmin": false
} The -c cookies.txt saves the HttpOnly
qnt_refresh cookie. If the account has MFA, login instead
returns { "mfa_required": true, "challenge_token": "..." }
— complete it with POST /auth/login/mfa
({ "challenge_token": "...", "code": "123456" }).
curl -s "$API/tunnels" -H "Authorization: Bearer $JWT" The JWT lasts 15 minutes. Renew it without re-entering credentials:
curl -X POST "$API/auth/refresh" -b cookies.txt -c cookies.txt { "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } Refresh rotates the cookie every time; reuse of an old cookie revokes the whole session (theft response).
Most tunnels are created by the agent at connect time
(mytunnel http 3000). The API lists, deletes, and applies
edge policy to them.
curl -s "$API/tunnels" -H "Authorization: Bearer $JWT" [
{
"id": "b1f2...", "subdomain": "acme-api",
"publicUrl": "https://acme-api.21tunnel.com",
"status": "active", "protocol": "http", "localPort": 3000,
"authEnabled": false, "bytesIn": 10240, "bytesOut": 88192,
"totalRequests": 142, "createdAt": "2026-08-14T09:12:03Z"
}
] Reserve a tunnel from the dashboard (agent connects to it later).
curl -X POST "$API/tunnels" \
-H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{ "localPort": 3000, "subdomain": "acme-api", "protocol": "http" }' { "id": "b1f2...", "subdomain": "acme-api",
"publicUrl": "https://acme-api.21tunnel.com",
"status": "inactive", "protocol": "http", "localPort": 3000 } subdomain, protocol
(http|https|websocket|tcp), ttlSeconds and
projectSlug are optional.
curl -X DELETE "$API/tunnels/$TID" -H "Authorization: Bearer $JWT" Traffic policy at the public edge — deny a path, rate-limit, set a header, or validate a JWT. Up to 16 actions.
curl -X PUT "$API/tunnels/$TID/policy" \
-H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{ "actions": [
{ "kind": "deny", "path_prefix": "/admin" },
{ "kind": "rate_limit", "requests_per_minute": 60, "per_client_ip": true }
] }' Also on tunnels: PUT /tunnels/:id/maintenance
(branded 503), POST /tunnels/:id/start and
/stop.
Tokens are how agents authenticate — scoped, budgeted, and revocable, independent of any user login. Mint one, hand it to CI or an AI agent.
curl -X POST "$API/tokens" \
-H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{ "name": "ci-runner", "ttlHours": 720,
"bandwidthLimitMbps": 100, "tunnelQuota": 10 }' {
"id": "9af1...", "name": "ci-runner", "nonce": "a1b2c3d4...",
"tunnelQuota": 10, "bandwidthLimitMbps": 100,
"issuedAt": 1786636800, "expiresAt": 1789228800, "revoked": false,
"wireBytes": "AAABZm9vYmFy...(base64 bincode)", "wireBytesLen": 312
} wireBytes is returned once.
Decode it to the file the CLI wants:
echo "AAABZm9vYmFy..." | base64 -d > token.bin
mytunnel http 3000 --token-file token.bin GET /tokens — list (safe shape, no
wireBytes).DELETE /tokens/:nonce — revoke by the hex
nonce (revoking a master key cascade-revokes its
children).POST /tokens/:nonce/rotate — mint a replacement with
the same scopes, revoke the old (returns wireBytes).PUT /tokens/:nonce/budget — set a monthly USD cap
({ "monthlyBudgetUsdCents": 5000 };
0 freezes spend, null uncaps).curl -X POST "$API/domains" \
-H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{ "domain": "tunnels.example.com" }' { "id": "d17c...", "domain": "tunnels.example.com",
"verified": false, "verificationToken": "21t_verify_abc123",
"boundTunnelId": null } Publish a TXT record
_21tunnel-challenge.tunnels.example.com = 21t_verify_abc123,
then trigger verification:
curl -X POST "$API/domains/$DID/verify" -H "Authorization: Bearer $JWT" Claim a globally-unique subdomain that survives agent restarts.
curl -X POST "$API/reserved-subdomains" \
-H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{ "subdomain": "acme" }' { "id": "77e1...", "subdomain": "acme",
"organization_id": "406ba3e4-...", "created_at": "2026-08-14T09:20:00Z" } A relay-native WireGuard VPN. You register your server's public key, 21tunnel reserves a sticky relay UDP endpoint and generates the configs. Your private keys never leave your box — only public keys are stored. Free on all plans. Full walkthrough: UDP tunnels.
curl -X POST "$API/private-networks" \
-H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{ "name": "home-lab", "cidr": "10.99.0.0/24" }' { "id": "44ee2195-...", "name": "home-lab", "cidr": "10.99.0.0/24",
"server_public_key": null, "public_endpoint": null,
"server_listen_port": null, "reserved_udp_port": null } Generate the server keypair on your box — the private key stays local:
wg genkey | tee server.key | wg pubkey # copy the printed public key curl -X PUT "$API/private-networks/$NET/server" \
-H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{ "server_public_key": "u8f3Kf...Zk=", "server_listen_port": 51820 }' { "id": "44ee2195-...", "name": "home-lab",
"server_public_key": "u8f3Kf...Zk=",
"public_endpoint": "agent.21tunnel.com:31099",
"server_listen_port": 51820, "reserved_udp_port": 31099 } 21tunnel reserved 31099 and derived the
public endpoint. Fetch the ready-to-run server config:
curl -s "$API/private-networks/$NET/server-config" -H "Authorization: Bearer $JWT" # response is { "config": "..." } — the wg0.conf text:
[Interface]
PrivateKey = <PASTE_SERVER_PRIVATE_KEY>
Address = 10.99.0.1/24
ListenPort = 51820
# then on your box: wg-quick up wg0
# mytunnel udp 51820 --public-port 31099 curl -X POST "$API/private-networks/$NET/devices" \
-H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{ "name": "my-phone", "public_key": "C3dEf...=" }' { "device": { "id": "a8fe...", "name": "my-phone",
"assigned_ip": "10.99.0.2", "public_key": "C3dEf...=" },
"network_cidr": "10.99.0.0/24" } Render that client's .conf (Endpoint points
at the relay, ready for the WireGuard app):
curl -s "$API/private-networks/$NET/devices/$DEV/config" -H "Authorization: Bearer $JWT" Org-scoped audit log. Optional limit,
start_time, action, actor query
params. GET /me/events is the personal security feed.
curl -s "$API/events?limit=50" -H "Authorization: Bearer $JWT" [
{ "id": "3f0a...", "timestamp": "2026-08-14T09:30:11Z",
"action": "tunnel_created", "resourceType": "tunnel",
"userEmail": "you@example.com", "ipAddress": "203.0.113.9" }
] Provision a signed ingestion URL; agents poll validated deliveries.
Validators: github, slack,
generic_hmac_sha256, dodo.
curl -X POST "$API/webhook-receivers" \
-H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{ "validator": "github", "secret": "whsec_...", "name": "ci-hooks" }' { "id": "e91b...", "validator": "github",
"url": "https://login.21tunnel.com/api/webhooks/e91b..." } Paste url into the vendor's webhook config.
Poll validated events at
GET /webhook-receivers/:id/events.
curl -X POST "$API/billing/checkout-session" \
-H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
-d '{ "plan": "pro", "billing_cycle": "monthly" }' { "checkout_url": "https://checkout.dodopayments.com/..." } Redirect the user there. Manage an existing
subscription with POST /billing/portal →
{ "portal_url": "..." }.
Failures return the right HTTP status and a stable machine code.
Branch on error, show message to humans.
{ "error": "upgrade_required", "message": "Managed private networks require a Pro plan." } | Status | error | When |
|---|---|---|
| 401 | unauthorized | Missing / invalid / expired bearer JWT. |
| 403 | forbidden | Authenticated, but role too low for the action. |
| 404 | not_found | No such resource in your org. |
| 409 | duplicate_name | Name already taken (tunnels, networks, subdomains). |
| 409 | network_full | No free addresses left in a VPN's CIDR. |
| 400 | invalid_cidr / invalid_name | Validation failed on the request body. |
| 402 | upgrade_required | Plan gate (edge auth, master keys, reserved-subdomain quota). Private VPN is not gated. |
| 429 | — | Rate limited (/auth/*). Includes retry_after_seconds. |