openapi: 3.1.0 info: title: Twins Proxy API version: 0.3.0 description: API-first control plane for Cloudflare-backed masking domains, certificates, and proxy routes. servers: - url: /v1 security: [] tags: - {name: Authentication, description: Registration, sign-in, MFA, passkeys, and password reset} - {name: Profile, description: The signed-in user's account, sessions, TOTP, and passkeys} - {name: API keys, description: Scoped programmatic access tokens} - {name: DNS connections, description: Connected DNS-provider credentials and zone verification} - {name: Sites, description: Origin definitions a domain proxies to} - {name: Domains, description: Masked domains and their provisioning lifecycle} - {name: Certificates, description: Certificate inventory, import, export, and revocation} - {name: Nodes, description: Bring Your Own Node registration and lifecycle (Freedom/Unlimited plans)} - {name: Billing, description: Plans, add-ons, Stripe checkout, and the billing portal} - {name: Account, description: Data export and self-serve workspace deletion} - {name: Operations, description: Operational summary and audit history} - {name: Abuse, description: Public abuse reporting} - {name: Platform administration, description: System-administrator endpoints} paths: /auth/register: post: tags: [Authentication] operationId: register requestBody: required: true content: application/json: schema: {$ref: '#/components/schemas/RegisterRequest'} responses: '201': {description: Account created, content: {application/json: {schema: {$ref: '#/components/schemas/AuthResponse'}}}} '409': {$ref: '#/components/responses/Conflict'} '422': {$ref: '#/components/responses/ValidationError'} /auth/login: post: tags: [Authentication] operationId: login requestBody: required: true content: application/json: schema: {$ref: '#/components/schemas/LoginRequest'} responses: '200': {description: Signed in, content: {application/json: {schema: {$ref: '#/components/schemas/AuthResponse'}}}} '401': {$ref: '#/components/responses/Unauthorized'} /auth/mfa/verify: post: tags: [Authentication] operationId: verifyMfaChallenge description: Completes sign-in using a five-minute MFA challenge and an authenticator or recovery code. requestBody: required: true content: application/json: schema: type: object required: [challenge_token, code] properties: challenge_token: {type: string, writeOnly: true} code: {type: string, writeOnly: true} responses: '200': {description: Signed in and secure session cookie issued} '401': {$ref: '#/components/responses/Unauthorized'} /auth/mfa/step-up: post: tags: [Authentication] operationId: stepUpMfa description: Re-verifies the current browser session for sensitive administrator actions for 15 minutes. security: [{bearerAuth: []}] responses: '200': {description: Session verification refreshed} /auth/options: get: tags: [Authentication] operationId: authOptions description: Public auth feature flags — whether registration is open and the Cloudflare Turnstile site key (empty when the bot check is disabled). responses: '200': {description: Auth options} /auth/passkey/begin: post: tags: [Authentication] operationId: beginPasskeyLogin description: Starts a discoverable WebAuthn (passkey) sign-in ceremony. Returns publicKey request options and a short-lived session_id. responses: '200': {description: WebAuthn assertion options} /auth/passkey/finish: post: tags: [Authentication] operationId: finishPasskeyLogin description: Completes the passkey ceremony and signs the user in. A passkey satisfies MFA, so no TOTP challenge follows. requestBody: required: true content: application/json: schema: type: object required: [session_id, credential] properties: session_id: {type: string} credential: {type: object, description: The serialized PublicKeyCredential assertion} responses: '200': {description: Signed in, content: {application/json: {schema: {$ref: '#/components/schemas/AuthResponse'}}}} '401': {$ref: '#/components/responses/Unauthorized'} /profile/passkeys: get: tags: [Profile] operationId: listPasskeys security: [{bearerAuth: []}] responses: '200': {description: Registered passkeys} /profile/passkeys/begin: post: tags: [Profile] operationId: beginPasskeyRegistration security: [{bearerAuth: []}] responses: '200': {description: WebAuthn creation options and session_id} /profile/passkeys/finish: post: tags: [Profile] operationId: finishPasskeyRegistration security: [{bearerAuth: []}] requestBody: required: true content: application/json: schema: type: object required: [session_id, credential] properties: session_id: {type: string} name: {type: string, maxLength: 80} credential: {type: object, description: The serialized PublicKeyCredential attestation} responses: '201': {description: Passkey registered} /profile/passkeys/{id}: delete: tags: [Profile] operationId: deletePasskey security: [{bearerAuth: []}] parameters: [{name: id, in: path, required: true, schema: {type: string, format: uuid}}] responses: '204': {description: Passkey removed} '404': {$ref: '#/components/responses/NotFound'} /profile: get: tags: [Profile] operationId: getProfile description: Current user profile, including the active workspace's role and a workspaces array ({id, name, role}) listing every workspace the user belongs to — the source for the dashboard workspace switcher. security: [{bearerAuth: []}] responses: '200': {description: Current user profile} patch: tags: [Profile] operationId: updateProfile security: [{bearerAuth: []}] responses: '200': {description: Profile updated} /profile/password: post: tags: [Profile] operationId: changePassword security: [{bearerAuth: []}] responses: '200': {description: Password changed and all sessions revoked} /profile/2fa: get: tags: [Profile] operationId: getMfaStatus security: [{bearerAuth: []}] responses: '200': {description: TOTP status and remaining recovery-code count} delete: tags: [Profile] operationId: disableMfa description: Disables TOTP for a non-administrator account after password and code verification. security: [{bearerAuth: []}] responses: '200': {description: Two-factor authentication disabled} '403': {description: Two-factor authentication is mandatory for platform administrators} /profile/2fa/setup: post: tags: [Profile] operationId: setupMfa description: Verifies the current password and returns a one-time encrypted-secret enrollment QR code. security: [{bearerAuth: []}] responses: '200': {description: Authenticator enrollment details; secret is returned only during setup} /profile/2fa/confirm: post: tags: [Profile] operationId: confirmMfa description: Confirms the first TOTP code and returns ten one-time recovery codes exactly once. security: [{bearerAuth: []}] responses: '200': {description: Two-factor authentication enabled and recovery codes returned} /domains/{id}/check-dns: post: tags: [Domains] operationId: checkDomainDns description: Queues an immediate TXT re-check for a manual-DNS domain instead of waiting for the next poll. security: [{bearerAuth: []}] responses: '202': {description: DNS check queued} /billing: get: tags: [Billing] operationId: getBilling description: Returns the organization plan, plan catalogue, and this month's domain, request, and bandwidth usage. security: [{bearerAuth: []}] responses: '200': {description: Plan, limits, and usage} /billing/checkout: post: tags: [Billing] operationId: createCheckout description: Creates a Stripe Checkout session for the basic or pro plan and returns its URL. security: [{bearerAuth: []}] responses: '200': {description: Stripe Checkout URL} '503': {description: Stripe is not configured} /billing/webhook: post: tags: [Billing] operationId: stripeWebhook description: Stripe webhook receiver (signature-verified) that applies plan upgrades and cancellations. responses: '200': {description: Event processed} /profile/2fa/recovery-codes: post: tags: [Profile] operationId: regenerateRecoveryCodes description: Verifies the current password and an MFA code, then replaces all remaining recovery codes with ten new ones returned exactly once. security: [{bearerAuth: []}] responses: '200': {description: New recovery codes returned; all previous codes are invalidated} /profile/sessions: get: tags: [Profile] operationId: listSessions description: Lists active browser sessions for the signed-in user. API keys are not accepted. security: [{bearerAuth: []}] responses: '200': {description: Active sessions with client, IP, activity, and current-session indicators} /profile/sessions/{id}: delete: tags: [Profile] operationId: revokeSession description: Revokes one active browser session owned by the signed-in user. security: [{bearerAuth: []}] parameters: - {name: id, in: path, required: true, schema: {type: string, format: uuid}} responses: '204': {description: Session revoked} '404': {$ref: '#/components/responses/NotFound'} /profile/sessions/revoke-others: post: tags: [Profile] operationId: revokeOtherSessions description: Revokes all browser sessions except the session making the request. security: [{bearerAuth: []}] responses: '200': {description: Number of sessions revoked} /api-keys: get: tags: [API keys] operationId: listApiKeys security: [{bearerAuth: []}] responses: '200': {description: Active API keys with secrets omitted} '401': {$ref: '#/components/responses/Unauthorized'} post: tags: [API keys] operationId: createApiKey description: 'Creates a scoped API key. Scope confinement applies: a browser session may grant any supported scope, but an API key may only grant scopes it already holds (403 scope_escalation). The certificates:export scope can only be granted by the workspace owner from a session (403 owner_required).' security: [{bearerAuth: []}] requestBody: required: true content: application/json: schema: {$ref: '#/components/schemas/CreateApiKeyRequest'} responses: '201': {description: API key created; token is returned exactly once} '401': {$ref: '#/components/responses/Unauthorized'} '403': {description: scope_escalation (requested a scope the caller lacks) or owner_required (certificates:export)} '422': {$ref: '#/components/responses/ValidationError'} /api-keys/{id}: delete: tags: [API keys] operationId: revokeApiKey security: [{bearerAuth: []}] parameters: - {name: id, in: path, required: true, schema: {type: string, format: uuid}} responses: '204': {description: API key revoked} /dns-connections: get: tags: [DNS connections] operationId: listDnsConnections security: [{bearerAuth: []}] responses: '200': {description: DNS connections with credentials redacted} '401': {$ref: '#/components/responses/Unauthorized'} /dns-connections/cloudflare: post: tags: [DNS connections] operationId: createCloudflareConnection description: Verifies and encrypts a labeled Cloudflare account-owned API token, then discovers every readable active zone. security: [{bearerAuth: []}] requestBody: required: true content: application/json: schema: {$ref: '#/components/schemas/CloudflareConnectionRequest'} responses: '201': {description: Connection verified and stored encrypted} '401': {$ref: '#/components/responses/Unauthorized'} '422': {$ref: '#/components/responses/ValidationError'} /dns-connections/{id}/zones: get: tags: [DNS connections] operationId: listCloudflareZones security: [{bearerAuth: []}] parameters: - {name: id, in: path, required: true, schema: {type: string, format: uuid}} responses: '200': {description: Zones readable by the selected credential} /dns-connections/{id}/zones/{zone_id}/verify-write: post: tags: [DNS connections] operationId: verifyCloudflareZoneWrite description: Creates and immediately deletes a randomized temporary TXT record. security: [{bearerAuth: []}] parameters: - {name: id, in: path, required: true, schema: {type: string, format: uuid}} - {name: zone_id, in: path, required: true, schema: {type: string, format: uuid}} responses: '200': {description: DNS write permission verified and temporary record removed} '422': {$ref: '#/components/responses/ValidationError'} /sites: get: tags: [Sites] operationId: listSites security: [{bearerAuth: []}] responses: '200': {description: Organization sites} '401': {$ref: '#/components/responses/Unauthorized'} post: tags: [Sites] operationId: createSite security: [{bearerAuth: []}] requestBody: required: true content: application/json: schema: {$ref: '#/components/schemas/CreateSiteRequest'} responses: '201': {description: Site created, content: {application/json: {schema: {$ref: '#/components/schemas/Site'}}}} '401': {$ref: '#/components/responses/Unauthorized'} '422': {$ref: '#/components/responses/ValidationError'} /domains: get: tags: [Domains] operationId: listDomains security: [{bearerAuth: []}] responses: '200': {description: Organization domains and provisioning status} '401': {$ref: '#/components/responses/Unauthorized'} post: tags: [Domains] operationId: createDomain security: [{bearerAuth: []}] requestBody: required: true content: application/json: schema: {$ref: '#/components/schemas/CreateDomainRequest'} responses: '202': {description: Domain accepted for asynchronous provisioning, content: {application/json: {schema: {$ref: '#/components/schemas/Domain'}}}} '401': {$ref: '#/components/responses/Unauthorized'} '402': {description: 'Plan domain limit reached (plan_limit_reached) — upgrade to add more'} '409': {$ref: '#/components/responses/Conflict'} '422': {$ref: '#/components/responses/ValidationError'} '429': {description: 'Too many domains created recently (domain_velocity_exceeded). Retry-After header gives the seconds until a slot frees up. Limits are per rolling 24h: free 5, basic 20, pro 50, freedom 100.'} /domains/{id}: delete: tags: [Domains] operationId: suspendDomain description: Suspends the domain, removes its Caddy route, and deletes stored certificate files. security: [{bearerAuth: []}] parameters: - {$ref: '#/components/parameters/DomainId'} responses: '200': {description: Domain suspended and route removed} '202': {description: Domain suspended; route reconciliation is pending} '401': {$ref: '#/components/responses/Unauthorized'} '404': {$ref: '#/components/responses/NotFound'} /domains/{id}/events: get: tags: [Domains] operationId: listDomainEvents security: [{bearerAuth: []}] parameters: - {$ref: '#/components/parameters/DomainId'} responses: '200': {description: Up to 100 newest provisioning events} '401': {$ref: '#/components/responses/Unauthorized'} /domains/{id}/proxy: patch: tags: [Domains] operationId: configureDomainProxy description: Configures an origin after the wildcard certificate is ready and optionally updates Cloudflare routing DNS. security: [{bearerAuth: []}] parameters: - {$ref: '#/components/parameters/DomainId'} responses: '202': {description: Proxy configuration accepted} '409': {$ref: '#/components/responses/Conflict'} /domains/{id}/hostnames: get: tags: [Domains] operationId: listHostnames description: Lists the exact hostnames served for a domain, the plan limit, and whether wildcard subdomain routing is on. Every domain always has at least its apex row. security: [{bearerAuth: []}] parameters: - {$ref: '#/components/parameters/DomainId'} responses: '200': {description: Hostnames, wildcard_routing flag, and the per-domain hostname limit} '401': {$ref: '#/components/responses/Unauthorized'} '404': {$ref: '#/components/responses/NotFound'} post: tags: [Domains] operationId: addHostname description: Adds a hostname under the domain's own apex (the wildcard certificate covers *.apex and nothing else). On a Cloudflare-automated domain the CNAME is created before the row — but an existing DNS record pointing somewhere else is never overwritten (409 dns_record_exists); otherwise the response carries manual DNS instructions. Globally unique — a hostname already routed anywhere returns 409. security: [{bearerAuth: []}] parameters: - {$ref: '#/components/parameters/DomainId'} requestBody: required: true content: application/json: schema: type: object required: [hostname] properties: hostname: {type: string, example: go.example.com} responses: '201': {description: Hostname added; verified is true when we created the DNS ourselves} '402': {description: Per-domain hostname limit reached for the plan} '409': {description: That hostname is already routed, or it has an existing DNS record pointing somewhere else} '422': {description: Hostname is not under this domain's apex} /domains/{id}/hostnames/{hostname}: delete: tags: [Domains] operationId: removeHostname description: Removes a routed hostname. Only a DNS record the platform created is cleaned up; a customer's own CNAME is left in place. The apex hostname cannot be removed — delete the domain instead. security: [{bearerAuth: []}] parameters: - {$ref: '#/components/parameters/DomainId'} - {name: hostname, in: path, required: true, schema: {type: string}} responses: '204': {description: Hostname removed} '404': {$ref: '#/components/responses/NotFound'} '409': {description: The apex hostname cannot be removed} /domains/{id}/wildcard: patch: tags: [Domains] operationId: setWildcardRouting description: Toggles a *.apex catch-all site block (paid plans). Enabling it changes what every subdomain resolves to and needs a wildcard DNS record; Caddy still prefers an explicit hostname over the wildcard. security: [{bearerAuth: []}] parameters: - {$ref: '#/components/parameters/DomainId'} requestBody: required: true content: application/json: schema: type: object required: [enabled] properties: enabled: {type: boolean} responses: '200': {description: Wildcard routing updated} '402': {description: Wildcard routing requires a paid plan} '404': {$ref: '#/components/responses/NotFound'} /certificates: get: tags: [Certificates] operationId: listCertificates security: [{bearerAuth: []}] responses: '200': {description: Production wildcard certificate inventory} /certificates/import: post: tags: [Certificates] operationId: importCertificate description: Uploads a customer-owned certificate + private key for one of the organization's domains. The pair must match, be within its validity window, and cover the apex and www hostnames. Imported certificates are not auto-renewed and are not revoked at a CA when removed. security: [{bearerAuth: []}] requestBody: required: true content: application/json: schema: type: object required: [domain_id, certificate_pem, private_key_pem] properties: domain_id: {type: string, format: uuid} certificate_pem: {type: string, description: PEM chain, leaf certificate first} private_key_pem: {type: string, writeOnly: true} responses: '201': {description: Certificate imported and deployed} '404': {$ref: '#/components/responses/NotFound'} '422': {$ref: '#/components/responses/ValidationError'} /certificates/{id}: delete: tags: [Certificates] operationId: revokeCertificate description: Permanently revokes the certificate with the CA and suspends its proxy. security: [{bearerAuth: []}] parameters: - {name: id, in: path, required: true, schema: {type: string, format: uuid}} responses: '200': {description: Certificate revoked and proxy suspended} '502': {description: Certificate authority rejected revocation} /auth/logout: post: tags: [Authentication] operationId: logout description: Revokes the current browser session and clears its cookie. security: [{bearerAuth: []}] responses: '204': {description: Signed out} /auth/verify-email: post: tags: [Authentication] operationId: verifyEmail description: Consumes a single-use email-verification token, marks the address verified, and starts a session (auto sign-in). requestBody: required: true content: application/json: schema: type: object required: [token] properties: token: {type: string, writeOnly: true} responses: '200': {description: Email verified and signed in, content: {application/json: {schema: {$ref: '#/components/schemas/AuthResponse'}}}} '422': {$ref: '#/components/responses/ValidationError'} /auth/verify-email/resend: post: tags: [Authentication] operationId: resendVerificationEmail description: Re-sends the verification email. Rate-limited and always returns 202 to avoid disclosing whether the address exists. requestBody: required: true content: application/json: schema: type: object required: [email] properties: email: {type: string, format: email} responses: '202': {description: Verification email sent if the address is eligible} /auth/password-reset/request: post: tags: [Authentication] operationId: requestPasswordReset description: Emails a password-reset link (30-minute single-use token). Always 202 regardless of whether the address exists. requestBody: required: true content: application/json: schema: type: object required: [email] properties: email: {type: string, format: email} responses: '202': {description: Reset email sent if the address exists} /auth/password-reset/confirm: post: tags: [Authentication] operationId: confirmPasswordReset description: Sets a new password using a valid reset token and revokes all existing sessions. requestBody: required: true content: application/json: schema: type: object required: [token, password] properties: token: {type: string, writeOnly: true} password: {type: string, minLength: 12, maxLength: 128, writeOnly: true} responses: '200': {description: Password reset} '422': {$ref: '#/components/responses/ValidationError'} /sites/{id}: patch: tags: [Sites] operationId: updateSite description: Updates a site, including optional origin-firewall allowlisting settings (a custom upstream User-Agent and a secret identity header) so origins behind a WAF/bot control can whitelist proxy traffic. security: [{bearerAuth: []}] parameters: - {name: id, in: path, required: true, schema: {type: string, format: uuid}} requestBody: required: true content: application/json: schema: {$ref: '#/components/schemas/UpdateSiteRequest'} responses: '200': {description: Site updated, content: {application/json: {schema: {$ref: '#/components/schemas/Site'}}}} '404': {$ref: '#/components/responses/NotFound'} '422': {$ref: '#/components/responses/ValidationError'} /domains/{id}/retry: post: tags: [Domains] operationId: retryDomain description: Re-queues a failed or stuck domain for immediate provisioning. security: [{bearerAuth: []}] parameters: [{$ref: '#/components/parameters/DomainId'}] responses: '202': {description: Domain re-queued} '404': {$ref: '#/components/responses/NotFound'} /domains/{id}/certificate/reissue: post: tags: [Domains] operationId: reissueDomainCertificate description: Forces a fresh certificate issuance for the domain. security: [{bearerAuth: []}] parameters: [{$ref: '#/components/parameters/DomainId'}] responses: '202': {description: Reissue queued} '404': {$ref: '#/components/responses/NotFound'} /domains/{id}/ipv6: patch: tags: [Domains] operationId: setDomainIpv6 description: Toggles best-effort IPv6 (AAAA) for a domain whose IPv6 is operator-managed. Enabling publishes the AAAA; disabling removes it. Returns 409 when IPv6 for the domain is manual (customer-managed BYON node or manual/delegated DNS). security: [{bearerAuth: []}] parameters: [{$ref: '#/components/parameters/DomainId'}] requestBody: required: true content: application/json: schema: type: object required: [enabled] properties: enabled: {type: boolean} responses: '200': {description: IPv6 preference updated} '409': {description: IPv6 for this domain is not operator-managed (ipv6_not_managed)} /domains/{id}/noindex: patch: tags: [Domains] operationId: setDomainNoindex description: Toggles the X-Robots-Tag "noindex, nofollow" response header for a masked domain. When enabled the edge sends the header on every response (including the HTTP-to-HTTPS redirect), telling search engines not to index the domain as duplicate content of the origin site. New domains start with it ON; disabling it makes the domain indexable. security: [{bearerAuth: []}] parameters: [{$ref: '#/components/parameters/DomainId'}] requestBody: required: true content: application/json: schema: type: object required: [enabled] properties: enabled: {type: boolean} responses: '200': {description: Noindex preference updated} '404': {$ref: '#/components/responses/NotFound'} /domains/{id}/archive: delete: tags: [Domains] operationId: deleteDomain description: Permanently removes a domain from any state, revoking unrevoked ACME certificates at the CA (best-effort), unlinking certificate files, and freeing the hostname immediately. security: [{bearerAuth: []}] parameters: [{$ref: '#/components/parameters/DomainId'}] responses: '200': {description: Domain permanently deleted} '404': {$ref: '#/components/responses/NotFound'} /certificates/{id}/export: get: tags: [Certificates] operationId: exportCertificate description: Returns the certificate chain and private key in PEM for a Freedom/Unlimited workspace. Browser sessions must pass a fresh MFA step-up; API keys require the dedicated certificates:export scope. Handling the private key is the caller's responsibility. security: [{bearerAuth: []}] parameters: [{name: id, in: path, required: true, schema: {type: string, format: uuid}}] responses: '200': description: Certificate and private-key PEM content: application/json: schema: type: object properties: domain: {type: string} certificate_pem: {type: string} private_key_pem: {type: string, writeOnly: true} '403': {description: Export not allowed on this plan, or MFA step-up required (mfa_step_up_required)} '404': {$ref: '#/components/responses/NotFound'} /dns-connections/{provider}: post: tags: [DNS connections] operationId: createDnsConnection description: Connects a labeled DNS-provider credential and discovers its writable zones. The credential is verified for read and write access, then stored AES-256-GCM encrypted. For two-part providers (Route 53, GoDaddy) supply the credential as "id:secret" in api_token. security: [{bearerAuth: []}] parameters: - name: provider in: path required: true schema: {type: string, enum: [cloudflare, route53, godaddy, digitalocean]} requestBody: required: true content: application/json: schema: type: object required: [label, api_token] properties: label: {type: string, maxLength: 120} api_token: {type: string, writeOnly: true, description: 'Provider token; "id:secret" for Route 53 and GoDaddy'} responses: '201': {description: Connection verified and stored encrypted} '404': {description: Unsupported DNS provider} '422': {$ref: '#/components/responses/ValidationError'} /dns-connections/{id}: patch: tags: [DNS connections] operationId: updateDnsConnection description: Renames a connection and optionally rotates its credential (re-verified when a new token is supplied). security: [{bearerAuth: []}] parameters: [{name: id, in: path, required: true, schema: {type: string, format: uuid}}] requestBody: required: true content: application/json: schema: type: object required: [label] properties: label: {type: string, maxLength: 120} api_token: {type: string, writeOnly: true, description: Optional — rotates the stored credential} responses: '200': {description: Connection updated} '409': {description: A credential with that label already exists (label_exists)} '422': {$ref: '#/components/responses/ValidationError'} delete: tags: [DNS connections] operationId: deleteDnsConnection description: Removes a DNS connection. Fails if domains still reference it. security: [{bearerAuth: []}] parameters: [{name: id, in: path, required: true, schema: {type: string, format: uuid}}] responses: '204': {description: Connection removed} '409': {$ref: '#/components/responses/Conflict'} /dns-connections/{id}/verify: post: tags: [DNS connections] operationId: verifyDnsConnection description: Re-checks the stored credential's read/write access and refreshes zone verification. security: [{bearerAuth: []}] parameters: [{name: id, in: path, required: true, schema: {type: string, format: uuid}}] responses: '200': {description: Connection re-verified} '404': {$ref: '#/components/responses/NotFound'} /nodes: get: tags: [Nodes] operationId: listNodes description: Lists the organization's Bring Your Own Node instances and their status. security: [{bearerAuth: []}] responses: '200': {description: Nodes with status, IPs, and last-seen} post: tags: [Nodes] operationId: createNode description: Declares a new BYON node (Freedom/Unlimited plans). IPv4 is required; IPv6 is optional; an IPv6-only node is rejected. Returns an enrollment token for the installer. security: [{bearerAuth: []}] requestBody: required: true content: application/json: schema: type: object required: [name, public_ipv4] properties: name: {type: string, maxLength: 120} public_ipv4: {type: string, format: ipv4} public_ipv6: {type: string, format: ipv6} private_ipv4: {type: string, format: ipv4} responses: '201': {description: Node declared; enrollment token returned once} '402': {description: Node not available on the current plan (plan_required)} '422': {$ref: '#/components/responses/ValidationError'} /nodes/{id}: delete: tags: [Nodes] operationId: removeNode description: Permanently removes a node. Its domains are detached and suspended; its live certificates are revoked; the credential dies after a 24-hour decommission grace and can never re-attach. security: [{bearerAuth: []}] parameters: - {name: id, in: path, required: true, schema: {type: string, format: uuid}} - {name: mode, in: query, schema: {type: string, enum: [uninstall, leave]}} responses: '200': {description: Node removed} '404': {$ref: '#/components/responses/NotFound'} /nodes/{id}/enrollment: post: tags: [Nodes] operationId: reissueNodeEnrollment description: Issues a fresh single-use, 15-minute enrollment token for a node. security: [{bearerAuth: []}] parameters: [{name: id, in: path, required: true, schema: {type: string, format: uuid}}] responses: '201': {description: New enrollment token returned once} '404': {$ref: '#/components/responses/NotFound'} /nodes/{id}/change-ip: post: tags: [Nodes] operationId: changeNodeIp description: Updates a node's public IP(s) and re-verifies ownership against the new address. Omitting public_ipv6 leaves it unchanged; sending an empty string clears it. security: [{bearerAuth: []}] parameters: [{name: id, in: path, required: true, schema: {type: string, format: uuid}}] requestBody: required: true content: application/json: schema: type: object required: [public_ipv4] properties: public_ipv4: {type: string, format: ipv4} public_ipv6: {type: string, description: Omit to keep unchanged; empty string clears} responses: '202': {description: Node re-verifying against the new IP} '422': {$ref: '#/components/responses/ValidationError'} /billing/change-plan: post: tags: [Billing] operationId: changePlan description: Owner only (members get 403 owner_required, as on every billing mutation). Upgrades or downgrades the workspace plan. Downgrades are gated by criteria (domain count within limit, per-domain hostnames and member seats within the target's allowance, no nodes unless the target allows them); with a live subscription the Stripe price is swapped prorated, free cancels the subscription, and a paid target without a subscription returns 409 checkout_required. security: [{bearerAuth: []}] requestBody: required: true content: application/json: schema: type: object required: [plan] properties: plan: {type: string, enum: [free, basic, pro, freedom]} responses: '200': {description: Plan changed} '409': {description: Criteria not met, already on plan, or checkout required} '422': {$ref: '#/components/responses/ValidationError'} /billing/addons: post: tags: [Billing] operationId: changeAddon description: Sets the quantity of a stackable add-on. Increases charge the prorated amount immediately (entitlement granted only once the invoice is paid); decreases credit the next invoice. Returns payment_action_required with a hosted invoice URL when a card needs authentication. security: [{bearerAuth: []}] requestBody: required: true content: application/json: schema: {$ref: '#/components/schemas/AddonRequest'} responses: '200': {description: Add-on quantity updated} '402': {description: Add-ons require a paid plan (plan_required) or payment action required} '409': {description: Add-ons on this plan are admin-managed} '422': {$ref: '#/components/responses/ValidationError'} '503': {description: The add-on is not configured in Stripe (addon_not_configured)} /billing/addons/preview: post: tags: [Billing] operationId: previewAddon description: Returns the immediate prorated charge and the resulting monthly total for a prospective add-on quantity change, without applying it. security: [{bearerAuth: []}] requestBody: required: true content: application/json: schema: {$ref: '#/components/schemas/AddonRequest'} responses: '200': {description: Proration preview (charge_now_cents and new monthly total)} '422': {$ref: '#/components/responses/ValidationError'} /billing/portal: post: tags: [Billing] operationId: openBillingPortal description: Opens a Stripe Billing Portal session (creating the customer on first use) for managing saved cards and invoices. security: [{bearerAuth: []}] responses: '200': {description: Billing portal URL} '503': {description: Stripe is not configured} /audit-events: get: tags: [Operations] operationId: listAuditEvents description: Returns the organization's recent audit trail. security: [{bearerAuth: []}] responses: '200': {description: Audit events} /operations/summary: get: tags: [Operations] operationId: operationsSummary description: Operational counts for the organization (domains by status, certificates, and recent activity). security: [{bearerAuth: []}] responses: '200': {description: Operational summary} /abuse-reports: post: tags: [Abuse] operationId: submitAbuseReport description: Public endpoint to report abuse of a served domain. Accepts multipart/form-data (with an optional evidence attachment up to 25 MB) or JSON. Turnstile-verified when configured and rate-limited to 10/hour. Name and email are required. requestBody: required: true content: multipart/form-data: schema: type: object required: [reported_url, category, description, reporter_name, reporter_email] properties: reported_url: {type: string} category: {type: string, enum: [phishing, malware, spam, copyright, trademark, child_safety, fraud, other]} description: {type: string} reporter_name: {type: string} reporter_email: {type: string, format: email} turnstile_token: {type: string} attachment: {type: string, format: binary} application/json: schema: type: object required: [reported_url, category, description, reporter_name, reporter_email] properties: reported_url: {type: string} category: {type: string, enum: [phishing, malware, spam, copyright, trademark, child_safety, fraud, other]} description: {type: string} reporter_name: {type: string} reporter_email: {type: string, format: email} responses: '201': {description: Report received} '422': {$ref: '#/components/responses/ValidationError'} '429': {description: Too many reports; try again later} /admin/summary: get: tags: [Platform administration] operationId: adminSummary description: Platform-wide operational counts. Requires a system-administrator account. security: [{bearerAuth: []}] responses: '200': {description: Platform summary} '403': {description: System administrator access required} /admin/users: get: tags: [Platform administration] operationId: adminListUsers security: [{bearerAuth: []}] responses: '200': {description: Platform users and organization summaries} /admin/users/{id}: patch: tags: [Platform administration] operationId: adminUpdateUser description: Enables or disables an account. Disabling revokes its sessions and API keys. security: [{bearerAuth: []}] parameters: - {name: id, in: path, required: true, schema: {type: string, format: uuid}} responses: '200': {description: Account status updated} /admin/domains: get: tags: [Platform administration] operationId: adminListDomains security: [{bearerAuth: []}] responses: '200': {description: Domains across all organizations} /admin/domains/{id}/events: get: tags: [Platform administration] operationId: adminDomainEvents security: [{bearerAuth: []}] parameters: - {$ref: '#/components/parameters/DomainId'} responses: '200': {description: Domain provisioning history} /admin/domains/{id}/actions: post: tags: [Platform administration] operationId: adminDomainAction description: Retries, suspends, resumes, or reissues the certificate for a domain. security: [{bearerAuth: []}] parameters: - {$ref: '#/components/parameters/DomainId'} requestBody: required: true content: application/json: schema: type: object required: [action] properties: action: {type: string, enum: [retry, suspend, resume, reissue]} responses: '202': {description: Administrator action accepted} '409': {$ref: '#/components/responses/Conflict'} /account/export: get: tags: [Account] operationId: exportAccount description: Streams a zip of the workspace (profile, organization, domains, sites, usage_daily.csv, audit_events.csv, manifest.json). Session-only, rate-limited 3/hour. Deliberately excludes every secret — DNS credentials, the TOTP secret, the SFTP password, certificate private keys, and token hashes — naming each as excluded in the manifest. A truncated section is declared in the manifest rather than silently dropped. security: [{bearerAuth: []}] responses: '200': {description: A zip archive of the workspace's data (application/zip)} '401': {$ref: '#/components/responses/Unauthorized'} '403': {description: An API key cannot export — a browser session is required} '429': {description: Rate limit exceeded (3/hour)} /team/members: get: tags: [Team] operationId: listTeamMembers description: Lists the workspace's members with roles, plus the plan's seat total and seats used (members + pending invitations). Visible to every member. security: [{bearerAuth: []}] responses: '200': {description: Members, seats, and seats_used} /team/members/{user_id}: delete: tags: [Team] operationId: removeTeamMember description: 'Owner only. Removes a member: their sessions in this workspace are revoked and API keys they created here are revoked (the response carries revoked_api_keys). If this was their last workspace a personal fallback workspace is created so the account can still sign in. The owner cannot be removed — delete the workspace instead.' security: [{bearerAuth: []}] parameters: [{name: user_id, in: path, required: true, schema: {type: string}}] responses: '200': {description: Member removed} '403': {description: owner_required} '404': {description: Not a member of this workspace} '409': {description: The owner cannot remove themselves} /team/invitations: get: tags: [Team] operationId: listInvitations description: Owner only. Pending invitations (unaccepted, unrevoked, unexpired). security: [{bearerAuth: []}] responses: '200': {description: Pending invitations} post: tags: [Team] operationId: createInvitation description: Owner only. Invites an email address; the invitation is valid 12 hours and reserves a seat. Seats per plan (including the owner) — free 1 (no invites), basic 2, pro 3, freedom 4, unlimited uncapped. Re-inviting an address revokes the previous link and issues a fresh one. Without a configured mailer the response carries invite_url for manual delivery. security: [{bearerAuth: []}] requestBody: required: true content: application/json: schema: type: object required: [email] properties: email: {type: string, example: teammate@company.com} responses: '201': {description: Invitation created (invite_url present only without a mailer)} '402': {description: seat_limit_reached} '403': {description: owner_required} '409': {description: Already a member of this workspace} /team/invitations/{id}: delete: tags: [Team] operationId: revokeInvitation description: Owner only. Revokes a pending invitation; the emailed link stops working immediately. security: [{bearerAuth: []}] parameters: [{name: id, in: path, required: true, schema: {type: string}}] responses: '204': {description: Revoked} '404': {description: Not found or no longer pending} /team/invitations/lookup: post: tags: [Team] operationId: lookupInvitation description: Public, rate limited. Resolves an invitation token to the invited email, workspace name, and whether the address already has an account. Possession of the 256-bit token is the gate; every failure shape is the same 404. requestBody: required: true content: application/json: schema: type: object required: [token] properties: token: {type: string} responses: '200': {description: email, organization_name, account_exists} '404': {description: invalid_invitation} /team/invitations/accept: post: tags: [Team] operationId: acceptInvitation description: 'Public, rate limited, two branches. With a session (Bearer or cookie): attaches the signed-in account as a member — the invitation must have been sent to that account''s email (403 wrong_account) — and repoints the session at the joined workspace. Without a session: creates the account (first_name, last_name, password required; the email comes from the invitation and is born verified) and returns a session. An invite token never signs into an EXISTING account — that returns 409 account_exists; sign in first.' requestBody: required: true content: application/json: schema: type: object required: [token] properties: token: {type: string} first_name: {type: string} last_name: {type: string} password: {type: string} responses: '200': {description: Joined (existing account)} '201': {description: Account created and joined; session token returned} '402': {description: seat_limit_reached (re-checked at accept)} '403': {description: wrong_account or session_required (API keys cannot accept)} '404': {description: invalid_invitation} '409': {description: account_exists or already_member} /auth/workspace: post: tags: [Team] operationId: switchWorkspace description: Repoints the current session at another workspace the user belongs to (the dashboard workspace switcher). Membership is checked; the MFA step-up state is preserved. Exempt from the deletion freeze so a frozen workspace never traps a multi-workspace user. security: [{bearerAuth: []}] requestBody: required: true content: application/json: schema: type: object required: [organization_id] properties: organization_id: {type: string} responses: '200': {description: Session switched} '404': {description: not_a_member} /account/deletion: get: tags: [Account] operationId: getAccountDeletion description: Reports whether a workspace purge is scheduled, and the purge date if so. security: [{bearerAuth: []}] responses: '200': {description: pending flag, and requested_at/purge_after when scheduled} '401': {$ref: '#/components/responses/Unauthorized'} post: tags: [Account] operationId: requestAccountDeletion description: Schedules workspace deletion. The service stops immediately (domains stop serving, DNS credentials are destroyed, other sessions are revoked); data is purged after the grace window. Owner-only, requires the password and the exact workspace name, and a fresh MFA code if the user has MFA enrolled. A workspace with other members needs delete_workspace=true. Any live Stripe subscription is cancelled first. Cancelling before the purge date restores the account and data, but not the domains. security: [{bearerAuth: []}] requestBody: required: true content: application/json: schema: type: object required: [password, confirm] properties: password: {type: string, format: password} confirm: {type: string, description: The exact workspace name} delete_workspace: {type: boolean, description: Required when the workspace has other members} responses: '202': {description: Deletion scheduled; service already stopped} '401': {description: Incorrect password} '403': {description: Only the workspace owner may delete it, or MFA step-up is required} '409': {description: Other members present (re-send with delete_workspace=true), or a deletion is already scheduled} '422': {description: Workspace-name confirmation did not match} '503': {description: The Stripe subscription could not be cancelled — nothing was deleted} delete: tags: [Account] operationId: cancelAccountDeletion description: Cancels a scheduled deletion and reactivates the workspace. Owner-only. Domains removed at request time are not restored. security: [{bearerAuth: []}] responses: '200': {description: Deletion cancelled} '403': {description: Only the workspace owner may cancel} '409': {description: No deletion is scheduled} components: securitySchemes: bearerAuth: type: http scheme: bearer description: Session token or scoped API key. parameters: DomainId: name: id in: path required: true schema: {type: string, format: uuid} responses: Unauthorized: description: Missing, invalid, or expired Bearer token content: {application/json: {schema: {$ref: '#/components/schemas/Error'}}} ValidationError: description: Request validation failed content: {application/json: {schema: {$ref: '#/components/schemas/Error'}}} Conflict: description: Resource already exists or conflicts with current state content: {application/json: {schema: {$ref: '#/components/schemas/Error'}}} NotFound: description: Resource not found content: {application/json: {schema: {$ref: '#/components/schemas/Error'}}} schemas: RegisterRequest: type: object required: [first_name, last_name, email, password, organization_name] properties: first_name: {type: string, maxLength: 80} last_name: {type: string, maxLength: 80} email: {type: string, format: email} password: {type: string, minLength: 12, maxLength: 128, writeOnly: true} organization_name: {type: string, maxLength: 120} turnstile_token: {type: string, writeOnly: true, description: Cloudflare Turnstile response — required when the server has Turnstile configured} LoginRequest: type: object required: [email, password] properties: email: {type: string, format: email} password: {type: string, writeOnly: true} AuthResponse: type: object properties: user_id: {type: string, format: uuid} organization_id: {type: string, format: uuid} token: {type: string, writeOnly: true} token_type: {type: string, const: Bearer} expires_in: {type: integer, example: 2592000} CreateApiKeyRequest: type: object required: [name, scopes] properties: name: {type: string, maxLength: 120} scopes: type: array minItems: 1 items: {type: string, enum: ['sites:read','sites:write','domains:read','domains:write','dns:read','dns:write','api_keys:read','api_keys:write','audit:read','certificates:export']} CloudflareConnectionRequest: type: object required: [label, api_token] properties: label: {type: string, maxLength: 120, example: Primary Cloudflare} api_token: {type: string, writeOnly: true, description: Account-owned cfat_ token recommended} CreateSiteRequest: type: object required: [name, origin_url] properties: name: {type: string, maxLength: 120} origin_url: {type: string, format: uri, example: 'https://example.com'} UpdateSiteRequest: type: object description: All fields optional; identity settings allowlist proxy traffic at a WAF-protected origin. properties: upstream_user_agent: {type: string, description: Overrides the User-Agent sent to the origin; forwards the visitor's UA as X-Original-User-Agent} identity_header_name: {type: string, description: Secret header name the origin can match on} identity_header_value: {type: string, writeOnly: true, description: Secret header value (e.g. a tpk_ token)} AddonRequest: type: object required: [addon, quantity] properties: addon: {type: string, enum: [extra_domains, bandwidth_block, extra_node, dedicated_ip]} quantity: {type: integer, minimum: 0} Site: type: object properties: id: {type: string, format: uuid} name: {type: string} origin_url: {type: string, format: uri} created_at: {type: string, format: date-time} updated_at: {type: string, format: date-time} CreateDomainRequest: type: object required: [dns_connection_id, dns_zone_id, domain] properties: dns_connection_id: {type: string, format: uuid} dns_zone_id: {type: string, format: uuid} domain: {type: string, example: go.example.com} noindex: {type: boolean, default: true, description: 'Hide the domain from search engines (X-Robots-Tag: noindex, nofollow). Defaults to true — indexing a masked domain creates duplicate content of the origin site; pass false to make it indexable, or toggle later via PATCH /domains/{id}/noindex.'} Domain: type: object properties: id: {type: string, format: uuid} site_id: {type: string, format: uuid} domain: {type: string} certificate_mode: {type: string, enum: [wildcard, hostnames]} status: {type: string, enum: [pending, verifying_ownership, configuring_dns, waiting_for_dns, issuing_certificate, testing_origin, active, degraded, action_required, suspended]} status_message: {type: string} dns_connection_id: {type: string, format: uuid} noindex: {type: boolean, description: 'When true the edge sends X-Robots-Tag "noindex, nofollow" on every response'} created_at: {type: string, format: date-time} updated_at: {type: string, format: date-time} Error: type: object properties: error: type: object properties: code: {type: string} message: {type: string}