DOCS · REST API

REST API, by curl.

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.

1 Overview

The REST API backs the 21tunnel dashboard. Anything you can click, you can curl. No SDK required.

Base URL & conventions

  • Base URL: https://login.21tunnel.com/api (self-hosted: http://127.0.0.1:9090). api.21tunnel.com is an equivalent alias.
  • Requests and responses are application/json. IDs are UUIDs; timestamps are RFC 3339 UTC.
  • Every route except /health, /ready and /auth/* needs an Authorization: Bearer <jwt> header.
  • Errors share one shape: { "error": "code", "message": "..." } — branch on the machine-readable error, never the prose.
  • Rate limit: /auth/* is 5-burst + 1 request / 12s per source IP. Other routes are unlimited at the app layer.

Health (public, no auth)

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

Set up your shell

export API="https://login.21tunnel.com/api"
export JWT="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."   # from POST /auth/login

Every example below uses these two variables.

2 Authentication

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.

POST/auth/login
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" }).

Use the token

curl -s "$API/tunnels" -H "Authorization: Bearer $JWT"

The JWT lasts 15 minutes. Renew it without re-entering credentials:

POST/auth/refresh
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).

3 Tunnels

Most tunnels are created by the agent at connect time (mytunnel http 3000). The API lists, deletes, and applies edge policy to them.

GET/tunnels
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"
  }
]
POST/tunnels

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.

Delete, maintenance, edge policy

DELETE/tunnels/:id
curl -X DELETE "$API/tunnels/$TID" -H "Authorization: Bearer $JWT"
PUT/tunnels/:id/policy

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.

4 Capability tokens

Tokens are how agents authenticate — scoped, budgeted, and revocable, independent of any user login. Mint one, hand it to CI or an AI agent.

POST/tokens
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

List, revoke, rotate, budget

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

5 Custom domains & reserved subdomains

POST/domains
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"
POST/reserved-subdomains

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" }

6 Private VPN FREE

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.

1 · Create a network

POST/private-networks
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 }

2 · Register your WireGuard server

Generate the server keypair on your box — the private key stays local:

wg genkey | tee server.key | wg pubkey   # copy the printed public key
PUT/private-networks/:id/server
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

3 · Enroll a client

POST/private-networks/:id/devices
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"

7 Audit log & webhooks

GET/events

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" }
]
POST/webhook-receivers

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.

8 Billing

POST/billing/checkout-session
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": "..." }.

9 Errors

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." }
StatuserrorWhen
401unauthorizedMissing / invalid / expired bearer JWT.
403forbiddenAuthenticated, but role too low for the action.
404not_foundNo such resource in your org.
409duplicate_nameName already taken (tunnels, networks, subdomains).
409network_fullNo free addresses left in a VPN's CIDR.
400invalid_cidr / invalid_nameValidation failed on the request body.
402upgrade_requiredPlan gate (edge auth, master keys, reserved-subdomain quota). Private VPN is not gated.
429Rate limited (/auth/*). Includes retry_after_seconds.

More