This document provides comprehensive API specifications for generating TypeScript frontend code that interacts with the LNVPS Lightning Network VPS service.
- Base URL:
https://api.lnvps.com(replace with actual production URL) - Authentication: either NIP-98 (Nostr) or an OAuth session token (see Authentication) on all authenticated endpoints
- Content Type:
application/json - Error Response Format:
{ "error": "Error message" } - Success Response Format:
{ "data": <response_data> }
DiskType: "hdd", "ssd"
DiskInterface: "sata", "scsi", "pcie"
VmState: "unknown", "running", "stopped", "creating"
CostPlanIntervalType: "day", "month", "year"
OsDistribution: "ubuntu", "debian", "centos", "fedora", "freebsd", "opensuse", "archlinux",
"redhatenterprise"
CpuMfg: "unknown", "intel", "amd", "apple", "nvidia", "arm"
CpuArch: "unknown", "x86_64", "arm64"
CpuFeature: "SSE", "SSE2", "SSE3", "SSSE3", "SSE4_1", "SSE4_2", "AVX", "AVX2", "FMA", "F16C",
"AVX512F", "AVX512VNNI", "AVX512BF16", "AVXVNNI", "NEON", "SVE", "SVE2", "AES", "SHA", "SHA512",
"PCLMULQDQ", "RNG", "GFNI", "VAES", "VPCLMULQDQ", "VMX", "NestedVirt", "AMX", "SME", "SGX", "SEV",
"TDX", "EncodeH264", "EncodeHEVC", "EncodeAV1", "EncodeVP9", "EncodeJPEG", "DecodeH264", "DecodeHEVC",
"DecodeAV1", "DecodeVP9", "DecodeJPEG", "DecodeMPEG2", "DecodeVC1", "VideoScaling", "VideoDeinterlace",
"VideoCSC", "VideoComposition"
Every authenticated endpoint accepts either of two Authorization schemes.
You never mix them on a single request — pick whichever the logged-in user has.
interface AuthHeaders {
// Nostr accounts: "Nostr <base64 NIP-98 event>"
// OAuth accounts: "Bearer <session JWT>"
'Authorization': string;
'Content-Type': 'application/json';
}- Nostr (NIP-98) —
Authorization: Nostr <base64-event>. Unchanged; used by users who log in with a Nostr key. - OAuth session token —
Authorization: Bearer <jwt>. Obtained from the OAuth login flow below. The token is opaque to the frontend: store it and echo it back on every request.
External login is a full-page browser redirect through the provider, ending back at your app with a session token. The frontend never sees the provider or the authorization code — only the final token.
[React] click "Login with Google"
→ window.location = `${API}/api/v1/oauth/google/login`
→ provider consent screen
→ provider redirects to `${API}/api/v1/oauth/google/callback` (registered with the provider, NOT your app)
→ API exchanges the code, creates/updates the account, issues a JWT
→ 302 to your configured success-redirect: `https://app.example.com/oauth/complete#token=<jwt>`
The token is delivered in the URL fragment (#token=…) so it is never sent
to or logged by any server. Provider tags are google, github, facebook,
apple (whichever are enabled server-side).
1. Start login (plain navigation, not fetch):
const API = import.meta.env.VITE_API_URL;
function startLogin(provider: 'google' | 'github' | 'facebook' | 'apple') {
window.location.href = `${API}/api/v1/oauth/${provider}/login`;
}Per-request return URL (optional). Pass ?redirect=<url> on the login
endpoint to override the server's configured success-redirect for this login
only — useful in local development so the browser lands back on your dev origin:
function startLogin(provider: string) {
const redirect = `${window.location.origin}/oauth/complete`;
window.location.href =
`${API}/api/v1/oauth/${provider}/login?redirect=${encodeURIComponent(redirect)}`;
}The requested URL is validated server-side against an allowlist and, once
accepted, round-tripped through the signed state so it cannot be tampered
with. A URL is accepted when its host is localhost (always allowed, for local
dev), or when it exactly equals / extends at a path boundary the configured
success-redirect or an entry in allowed-redirects. Anything else is rejected
with 400 (this prevents an open-redirect / token-theft hole where
?redirect=https://evil.com would leak the JWT). The provider-registered
/callback URL is never affected.
Server config (config.yaml) — allow a dev origin in addition to the default
success redirect:
oauth:
success-redirect: "https://app.lnvps.com/oauth/complete"
allowed-redirects:
- "http://localhost:3000"
providers:
# ...2. Handle the landing route (/oauth/complete): read the fragment, store the
token, scrub the URL:
const params = new URLSearchParams(window.location.hash.slice(1)); // drop '#'
const token = params.get('token');
if (token) {
localStorage.setItem('session_token', token);
window.history.replaceState({}, '', window.location.pathname); // remove token from history
// navigate to your authenticated area
}3. Call authenticated endpoints with the token:
fetch(`${API}/api/v1/account`, {
headers: { Authorization: `Bearer ${localStorage.getItem('session_token')}` },
});If the server is configured without a
success-redirect, the callback instead returns JSON{ "data": { "token": string, "token_type": "Bearer", "expires_in": number } }— suitable for a popup/postMessageflow. The redirect-fragment flow above is recommended for a standard SPA.
On first OAuth login the provider's email is synced into the account (and marked
verified when the provider asserts it), so AccountInfo.email /
email_verified may already be populated for these users.
Passwordless login with a platform passkey (Face ID / Touch ID / Windows Hello)
or a security key. Like OAuth, a passkey is the account (account_type is
oauth-style synthetic — see the note below) and login yields the same
Bearer session token. Unlike OAuth this is a fetch/XHR flow driven by the
browser's navigator.credentials API, not a redirect.
Each ceremony is two calls — start returns a challenge plus an opaque signed
state; you run the WebAuthn browser API, then post the result back with that
same state to finish, which returns the session token.
Register (new account):
import { startRegistration } from '@simplewebauthn/browser';
// 1. begin
const start = await fetch(`${API}/api/v1/webauthn/register/start`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'my device' }), // optional label
}).then(r => r.json());
// start.data = { challenge, state }
// 2. run the authenticator (challenge.publicKey per the WebAuthn spec)
const credential = await startRegistration(start.data.challenge.publicKey);
// 3. finish -> session token
const done = await fetch(`${API}/api/v1/webauthn/register/finish`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ state: start.data.state, credential }),
}).then(r => r.json());
localStorage.setItem('session_token', done.data.token); // { token, token_type, expires_in }Login (existing passkey, usernameless):
import { startAuthentication } from '@simplewebauthn/browser';
const start = await fetch(`${API}/api/v1/webauthn/login/start`, { method: 'POST' })
.then(r => r.json()); // { challenge, state }
const credential = await startAuthentication(start.data.challenge.publicKey);
const done = await fetch(`${API}/api/v1/webauthn/login/finish`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ state: start.data.state, credential }),
}).then(r => r.json());
localStorage.setItem('session_token', done.data.token);The signed state must be round-tripped unchanged and expires after ~5 minutes.
Passkey accounts are Nostr-less exactly like OAuth accounts — hide npub / NIP-17
UI for them (they are reported with account_type distinct from nostr).
interface AccountInfo {
email?: string;
email_verified?: boolean; // Present when email is set; true if email has been verified
account_type: 'nostr' | 'oauth' | 'webauthn'; // Read-only. Only 'nostr' has a usable Nostr key — hide npub / NIP-17 UI for 'oauth' and 'webauthn'. Enabling contact_nip17 for a non-'nostr' account is rejected.
contact_nip17: boolean;
contact_email: boolean;
country_code?: string; // ISO 3166-1 alpha-3 country code
name?: string;
address_1?: string;
address_2?: string;
city?: string;
state?: string;
postcode?: string;
tax_id?: string;
// Note: NWC wallets are no longer stored on the account. Add one via
// POST /api/v1/payment-methods (see "Saved Payment Methods").
tax?: AccountTaxInfo[]; // Read-only, GET only: tax (VAT) applied to payments, per seller company. Ignored on PATCH.
}
interface AccountTaxInfo {
company_id: number;
company_name: string;
rate: number; // VAT rate as a percentage, e.g. 23.0 for 23%
country_code?: string; // Place-of-supply country (ISO 3166-1 alpha-3), if determined
treatment: string; // "domestic" | "oss_b2c" | "reverse_charge" | "out_of_scope" | "undetermined_default"
}interface VmStatus {
id: number;
created: string; // ISO 8601 datetime
expires?: string; // ISO 8601 datetime — null/omitted for VMs not yet paid
mac_address: string;
image: VmOsImage;
template: VmTemplate;
ssh_key: UserSshKey;
ip_assignments: VmIpAssignment[];
status: VmRunningState; // Full running state with metrics; check status.state for the current lifecycle state
auto_renewal_enabled: boolean; // Whether automatic renewal via NWC is enabled for this VM
deleting_on?: string; // ISO 8601 datetime — date the VM will be deleted if not renewed (expiry + dynamic grace period); null/omitted for VMs not yet paid
subscription_id?: number; // The subscription this VM is billed under; renew via /api/v1/subscriptions/{id}/renew. null/omitted if never paid
}
interface VmRunningState {
timestamp: number; // Unix timestamp when state was collected
state: VmRunningStateKind;
cpu_usage: number; // CPU usage percentage (0.0–100.0)
mem_usage: number; // Memory usage percentage (0.0–100.0)
uptime: number; // Uptime in seconds
net_in: number; // Network bytes received
net_out: number; // Network bytes transmitted
disk_write: number; // Disk bytes written
disk_read: number; // Disk bytes read
}
// state field values:
// "unknown" — State not yet known (default before first poll)
// "running" — VM is running normally
// "stopped" — VM is shut down
// "creating" — First payment received; VM is being provisioned on the host for the first time
type VmRunningStateKind = 'unknown' | 'running' | 'stopped' | 'creating';interface VmTemplate {
id: number;
name: string;
created: string; // ISO 8601 datetime
expires?: string; // ISO 8601 datetime
cpu: number; // Number of CPU cores
cpu_mfg?: string; // CPU manufacturer (e.g. "intel", "amd"; omitted if unknown)
cpu_arch?: string; // CPU architecture (e.g. "x86_64", "arm64"; omitted if unknown)
cpu_features?: string[]; // Required CPU features (e.g. ["AVX2", "AES"]; omitted if empty)
memory: number; // Memory in bytes
disk_size: number; // Disk size in bytes
disk_type: 'hdd' | 'ssd';
disk_interface: 'sata' | 'scsi' | 'pcie';
cost_plan: VmCostPlan;
region: VmHostRegion;
}
interface VmCostPlan {
id: number;
name: string;
currency: 'BTC' | 'EUR' | 'USD';
amount: number; // Price amount in smallest currency units (cents for fiat, millisats for BTC)
other_price: Price[]; // Alternative currency prices
interval_amount: number;
interval_type: 'day' | 'month' | 'year';
}
interface Price {
currency: 'BTC' | 'EUR' | 'USD';
amount: number; // Amount in smallest currency units (cents for fiat, millisats for BTC)
}
interface VmHostRegion {
id: number;
name: string;
company_id: number; // Seller company id; match against account.tax[].company_id for the applicable VAT rate
}interface CustomVmRequest {
pricing_id: number;
cpu: number; // Number of CPU cores
memory: number; // Memory in bytes
disk: number; // Disk size in bytes
disk_type: 'hdd' | 'ssd';
disk_interface: 'sata' | 'scsi' | 'pcie';
}
interface CustomVmOrder extends CustomVmRequest {
image_id: number;
ssh_key_id: number;
ref_code?: string;
}
interface CustomTemplateParams {
id: number;
name: string;
region: VmHostRegion;
cpu_mfg?: string; // CPU manufacturer (e.g. "intel", "amd"; omitted if unknown)
cpu_arch?: string; // CPU architecture (e.g. "x86_64", "arm64"; omitted if unknown)
cpu_features?: string[]; // Required CPU features (e.g. ["AVX2", "AES"]; omitted if empty)
max_cpu: number;
min_cpu: number;
min_memory: number; // In bytes
max_memory: number; // In bytes
disks: CustomTemplateDiskParam[];
}
interface CustomTemplateDiskParam {
min_disk: number; // In bytes
max_disk: number; // In bytes
disk_type: 'hdd' | 'ssd';
disk_interface: 'sata' | 'scsi' | 'pcie';
}interface VmOsImage {
id: number;
distribution: 'ubuntu' | 'debian' | 'centos' | 'fedora' | 'freebsd' | 'opensuse' | 'archlinux' | 'redhatenterprise';
flavour: string;
version: string;
release_date: string; // ISO 8601 datetime
default_username?: string;
}
interface UserSshKey {
id: number;
name: string;
created: string; // ISO 8601 datetime
}
interface CreateSshKey {
name: string;
key_data: string; // SSH public key content
}interface VmIpAssignment {
id: number;
ip: string; // IP address with CIDR notation
gateway: string;
forward_dns?: string;
reverse_dns?: string;
}interface VmPayment {
id: string; // Hex-encoded payment ID
vm_id: number;
created: string; // ISO 8601 datetime
expires: string; // ISO 8601 datetime
amount: number; // Amount in smallest currency unit (cents for fiat, millisats for BTC)
tax: number; // Tax amount in smallest currency unit (cents for fiat, millisats for BTC)
processing_fee: number; // Processing fee in smallest currency unit (cents for fiat, millisats for BTC)
currency: string;
is_paid: boolean;
paid_at?: string; // ISO 8601 datetime when payment was completed (only present when is_paid is true)
data: PaymentData;
time: number; // Seconds this payment adds to VM expiry
is_upgrade: boolean;
upgrade_params?: string; // JSON-encoded upgrade parameters (only present for upgrade payments)
}
type PaymentData =
| { lightning: string } // Lightning Network invoice
| { revolut: { token: string } } // Revolut payment token
| { stripe: { session_id: string } }; // Stripe checkout session
interface PaymentType {
type: 'new' | 'renew' | 'upgrade';
}
interface PaymentMethod {
name: 'lightning' | 'revolut' | 'paypal' | 'stripe' | 'nwc' | 'lnurl';
metadata: Record<string, string>;
currencies: ('BTC' | 'EUR' | 'USD')[];
processing_fee_rate?: number; // Percentage rate (e.g., 1.0 for 1%)
processing_fee_base?: number; // Base amount in smallest currency units (cents for fiat, millisats for BTC)
processing_fee_currency?: string; // Currency for the base fee (e.g., "EUR")
}interface Subscription {
id: number;
name: string;
description?: string;
created: string; // ISO 8601 datetime
expires?: string; // ISO 8601 datetime
is_active: boolean;
auto_renewal_enabled: boolean;
line_items: SubscriptionLineItem[]; // Services included in this subscription
}
interface SubscriptionLineItem {
id: number;
subscription_id: number;
name: string;
description?: string;
price: Price; // Recurring cost per billing cycle
setup_fee: Price; // One-time setup fee
configuration?: object; // Raw upgrade bookkeeping only (e.g. new_cpu/new_memory/new_disk); NOT a resource link
resource?: SubscriptionLineItemResource; // Linked resource, resolved from the line item's subscription type
}
// Typed reference to the resource this line item bills for, resolved server-side
// from the line item's subscription type (null when there is no linked resource).
// Tagged union discriminated by the "type" field.
type SubscriptionLineItemResource =
| { type: "vps"; vm_id: number }
| { type: "ip_range"; ip_range_subscription_id: number };
interface SubscriptionPayment {
id: string; // Hex-encoded payment ID
subscription_id: number;
created: string; // ISO 8601 datetime
expires: string; // ISO 8601 datetime
amount: Price; // Total payment amount
payment_method: 'lightning' | 'revolut' | 'paypal' | 'stripe' | 'nwc' | 'lnurl';
payment_type: 'Purchase' | 'Renewal' | 'Upgrade';
is_paid: boolean;
paid_at?: string; // ISO 8601 datetime when payment was completed (only present when is_paid is true)
tax: Price; // Tax amount
processing_fee: Price; // Processing fee in the payment currency
}interface VmHistory {
id: number;
vm_id: number;
action_type: string;
timestamp: string; // ISO 8601 datetime
initiated_by: 'owner' | 'system' | 'other'; // Who initiated the action
previous_state?: string; // JSON string
new_state?: string; // JSON string
metadata?: string; // JSON string
description?: string;
}interface VmPatchRequest {
ssh_key_id?: number;
reverse_dns?: string;
auto_renewal_enabled?: boolean; // Enable/disable automatic renewal via NWC for this VM
}
interface CreateVmRequest {
template_id: number;
image_id: number;
ssh_key_id: number;
ref_code?: string;
}interface VmUpgradeRequest {
cpu?: number; // New CPU core count (must be >= current)
memory?: number; // New memory in bytes (must be >= current)
disk?: number; // New disk size in bytes (must be >= current)
}
interface VmUpgradeQuote {
cost_difference: Price; // Net pro-rated cost for remaining VM time (before tax)
new_renewal_cost: Price; // Monthly renewal cost after upgrade
discount: Price; // Amount discounted for remaining time on the old rate
tax: Price; // VAT charged on the upgrade cost
processing_fee: Price; // Payment processing fee added on top (zero for Lightning)
}Unauthenticated fetch endpoints (JSON in/out) for passwordless passkey login.
See Authentication for the full flow and browser
examples. Each ceremony is a start then finish pair; the opaque signed
state from start must be posted back to finish unchanged.
- POST
/api/v1/webauthn/register/start - Auth: None
- Body:
{ name?: string }(optional device label) - Response:
{ challenge: PublicKeyCredentialCreationOptions, state: string }
- POST
/api/v1/webauthn/register/finish - Auth: None
- Body:
{ state: string, credential: RegisterPublicKeyCredential, name?: string } - Response:
{ token: string, token_type: "Bearer", expires_in: number }(creates the account)
- POST
/api/v1/webauthn/login/start - Auth: None
- Body: none (usernameless / discoverable)
- Response:
{ challenge: PublicKeyCredentialRequestOptions, state: string }
- POST
/api/v1/webauthn/login/finish - Auth: None
- Body:
{ state: string, credential: PublicKeyCredential } - Response:
{ token: string, token_type: "Bearer", expires_in: number }
These endpoints are authenticated (any scheme — a Nostr, OAuth or passkey user can add passkeys to their own account). A passkey added here is stored against the current account, so a later discoverable login with it resolves back to that same account (its session token then carries the account's real identity — e.g. a Nostr user's npub still works).
- GET
/api/v1/webauthn/credentials— list this account's passkeys. Response:Array<{ id: number, name?: string, created: string, last_used?: string }> - POST
/api/v1/webauthn/credentials/start— begin adding a passkey. Body{ name?: string }; Response{ challenge, state }(already excludes credentials registered to this account). RunstartRegistration(...)then: - POST
/api/v1/webauthn/credentials/finish— Body{ state, credential, name? }; Response is the created{ id, name?, created, last_used? }(no session token — you are already logged in). - DELETE
/api/v1/webauthn/credentials/{id}— remove a passkey. A pure passkey account (account_type: "webauthn") cannot delete its only credential (that would lock the account out).
These endpoints are unauthenticated and drive full-page browser navigation
(not fetch/XHR). See Authentication for the full flow
and React example. {provider} is one of the enabled tags (google, github,
facebook, apple).
- GET
/api/v1/oauth/{provider}/login - Auth: None
- Query:
redirect(optional) — per-request post-login return URL, overriding the configuredsuccess-redirectfor this login only. Validated against the allowlist (localhosthost always allowed; otherwise must matchsuccess-redirector anallowed-redirectsentry exactly or at a path boundary). Rejected with400if not allowed. The validated value is signed intostate, so it cannot be tampered with. - Behavior: 302 redirect to the provider's consent screen. Navigate the
browser here (e.g.
window.location.href = ...).
- GET/POST
/api/v1/oauth/{provider}/callback - Auth: None
- Behavior: Handled by the provider redirect (POST for Apple
form_post). On success, either 302-redirects to the server's configuredsuccess-redirectwith the token in the URL fragment (#token=<jwt>), or \u2014 if no redirect is configured \u2014 returns{ "data": { "token": string, "token_type": "Bearer", "expires_in": number } }. The frontend does not call this directly.
- GET
/api/v1/account - Auth: Required
- Response:
AccountInfo - Notes: The
taxfield lists the VAT rate that will currently be charged to the user for each seller company, determined from the user's billing info (VAT number, declared country, IP-derived country). Use it to show expected tax up-front; the authoritative amount is still computed per payment.
- PATCH
/api/v1/account - Auth: Required
- Body:
AccountInfo - Notes:
- Setting
contact_email: truerequires an email address to be present - When email is changed, a verification email is sent and
email_verifiedis reset tofalse
- Setting
- Response:
null
- GET
/api/v1/account/verify-email?token=<token> - Auth: Not required
- Query:
token— the verification token from the verification email - Response:
null
- GET
/api/v1/notification/channels - Auth: Not required
- Notes: Indicates which notification channels are configured on the server so the UI can show/hide the relevant contact inputs.
- Response:
{ "nip17": boolean, "email": boolean, "telegram": boolean, "whatsapp": boolean }
- POST
/api/v1/account/telegram/link - Auth: Required
- Notes: Generates a fresh one-time token and returns a Telegram deep link. Linking completes when the user opens the URL and presses Start in the bot. Returns an error if Telegram notifications are not enabled on the server.
- Response:
{ "url": string, "token": string }— e.g.{ "url": "https://t.me/MyBot?start=<token>", "token": "<token>" }
- DELETE
/api/v1/account/telegram/link - Auth: Required
- Notes: Clears the linked chat and link token and sets
contact_telegramtofalse. - Response:
null
- POST
/api/v1/account/whatsapp/verify - Auth: Required
- Body:
{ "number": string }— phone number in E.164 format, e.g.+15551234567 - Notes: Stores the number, generates a 6-digit code and sends it via the configured WhatsApp verification template. Returns an error if WhatsApp notifications are not enabled on the server, if the number is invalid, or if the message fails to send.
- Response:
null
- POST
/api/v1/account/whatsapp/confirm - Auth: Required
- Body:
{ "code": string }— the 6-digit code received via WhatsApp - Notes: On a correct code, marks the number verified and sets
contact_whatsapptotrue. Returns an error for an invalid or expired code. - Response:
null
- DELETE
/api/v1/account/whatsapp/verify - Auth: Required
- Notes: Removes the stored number, clears verification state and sets
contact_whatsapptofalse. - Response:
null
The LNVPS platform supports automatic VM renewal using Nostr Wallet Connect (NWC). This feature allows users to set up their Lightning wallets to automatically pay for VM renewals before expiration.
- User Setup: Configure your NWC connection string in your account settings
- Per-VM Control: Enable automatic renewal for specific VMs you want to auto-renew
- Automatic Processing: The system attempts renewal 1 day before VM expiration
- Dual Requirements: Auto-renewal only works when BOTH conditions are met:
- User has a valid NWC connection string configured
- VM has
auto_renewal_enabledset totrue
- Configure NWC Connection: Add your NWC connection string as a saved payment method via
POST /api/v1/payment-methods(see Saved Payment Methods below) - Enable Per-VM: Use the VM PATCH endpoint to set
auto_renewal_enabled: truefor desired VMs - Monitor Status: Check VM details to see current auto-renewal status
The nwc_connection_string should be a valid Nostr Wallet Connect URI in the format:
nostr+walletconnect://relay_url?relay=ws://...&secret=...&pubkey=...
- Safety First: New VMs default to
auto_renewal_enabled: false- you must explicitly enable it - Cost Control: Only enable auto-renewal for VMs you definitely want to keep running
- Fallback: If auto-renewal fails, you'll receive the standard expiration notification
- Validation: The system validates NWC connection strings when you set them
- Encryption: NWC connection strings are encrypted in the database for security
// 1. Add an NWC connection as a saved payment method
const addNwc = {
nwc_connection_string: "nostr+walletconnect://relay.damus.io?relay=wss://relay.damus.io&secret=...",
name: "My wallet"
};
await api.post('/api/v1/payment-methods', addNwc);
// 2. Enable auto-renewal for a specific VM
const vmUpdate = {
auto_renewal_enabled: true
};
await api.patch('/api/v1/vm/123', vmUpdate);
// 3. Check VM auto-renewal status
const vmStatus = await api.get('/api/v1/vm/123');
console.log('Auto-renewal enabled:', vmStatus.data.auto_renewal_enabled);Saved payment methods are the wallets/cards used for automatic renewals and for referral payouts (NWC). The underlying provider tokens / NWC connection strings are never returned by the API. The two supported providers are nwc (Nostr Wallet Connect, added by the user) and revolut (a saved card, created during an interactive card payment).
- GET
/api/v1/payment-methods - Auth: Required
- Response:
PaymentMethodResponse[]
- POST
/api/v1/payment-methods - Auth: Required
- Body:
AddNwcPaymentMethodRequest - Response:
PaymentMethodResponse - Notes: The NWC connection is validated (it must expose
pay_invoice). The first method a user adds becomes their default. - Error: Returns an error if the connection string is empty, cannot be parsed, or does not allow
pay_invoice.
- PATCH
/api/v1/payment-methods/{id} - Auth: Required
- Body:
PatchPaymentMethodRequest - Response:
PaymentMethodResponse - Notes: Setting
is_default: trueclears the default flag on the user's other methods (only one default at a time).
- DELETE
/api/v1/payment-methods/{id} - Auth: Required
- Response:
null
Types:
interface PaymentMethodResponse {
id: number;
provider: "nwc" | "revolut"; // Payment processor
name?: string; // Optional user-defined label
created: string; // ISO 8601 datetime
card_brand?: string; // Card brand (revolut only)
card_last_four?: string; // Last 4 digits (revolut only)
exp_month?: number; // Card expiry month (revolut only)
exp_year?: number; // Card expiry year (revolut only)
is_default: boolean; // Whether this is the default method
enabled: boolean; // Whether this method is usable
}
interface AddNwcPaymentMethodRequest {
nwc_connection_string: string; // NWC URI (nostr+walletconnect://...)
name?: string; // Optional user-defined label
}
interface PatchPaymentMethodRequest {
is_default?: boolean; // Set/unset as the default method
enabled?: boolean; // Enable/disable this method
name?: string | null; // Set (string) or clear (null) the label; omit to leave unchanged
}- GET
/api/v1/ssh-key - Auth: Required
- Response:
UserSshKey[]
- POST
/api/v1/ssh-key - Auth: Required
- Body:
CreateSshKey - Response:
UserSshKey
- GET
/api/v1/vm - Auth: Required
- Response:
VmStatus[]
- GET
/api/v1/vm/{id} - Auth: Required
- Response:
VmStatus
- PATCH
/api/v1/vm/{id} - Auth: Required
- Body:
VmPatchRequest - Response:
null - Description: Updates VM settings including SSH key, reverse DNS, and automatic renewal preferences
- POST
/api/v1/vm - Auth: Required
- Body:
CreateVmRequest - Response:
VmStatus
- POST
/api/v1/vm/custom-template - Auth: Required
- Body:
CustomVmOrder - Response:
VmStatus
- POST
/api/v1/vm/{id}/upgrade/quote?method={payment_method} - Auth: Required
- Query Params:
method: Optional payment method ('lightning' | 'revolut' | 'paypal'). Defaults to 'lightning'
- Body:
VmUpgradeRequest - Response:
VmUpgradeQuote - Description: Calculate the pro-rated upgrade cost for remaining VM time and the new monthly renewal cost after upgrade. Available for both standard template VMs and custom template VMs. Cost is calculated in the currency appropriate for the selected payment method. The response includes the upgrade cost (cost_difference), new renewal cost, and the discount amount representing the value of remaining time at the old pricing rate.
- POST
/api/v1/vm/{id}/upgrade?method={payment_method} - Auth: Required
- Query Params:
method: Optional payment method ('lightning' | 'revolut' | 'nwc' | 'saved'). Defaults to 'lightning'payment_method_id: Optional; formethod=saved, the specific saved card to charge (omit to use the default saved card)
- Body:
VmUpgradeRequest - Response:
VmPayment - Description: Create a payment for upgrading VM specifications. The upgrade is applied after payment confirmation. Payment method determines the currency and payment provider used. Saved methods are collected on the spot the same way as renewals:
method=nwcpays via the user's saved Nostr Wallet Connect wallet, andmethod=savedcharges a saved Revolut card off-session (merchant-initiated). For these off-session methods the request briefly waits for settlement — the returnedVmPaymentis alreadyis_paid: trueif it settled within ~10s, otherwise it is returned pending and settles asynchronously. Important: Running VMs will be automatically stopped and restarted during the upgrade process to apply hardware changes.
- PATCH
/api/v1/vm/{id}/start - Auth: Required
- Response:
null
- PATCH
/api/v1/vm/{id}/stop - Auth: Required
- Response:
null
- PATCH
/api/v1/vm/{id}/restart - Auth: Required
- Response:
null
- PATCH
/api/v1/vm/{id}/re-install - Auth: Required
- Response:
null - Errors:
402 Payment Requiredif the VM is expired (renew it first);403 Forbiddenif the VM is not yours;404 Not Foundif the VM does not exist.
- WebSocket
/api/v1/vm/{id}/console - Auth: Query parameter
?auth=<base64_nip98_event>(same base64-encoded NIP-98 event as theAuthorizationheader) - Protocol: WebSocket upgrade — bidirectional relay between the client and the VM's serial console
- Description: Opens a WebSocket connection to the VM's serial terminal. Raw bytes in either direction are forwarded to/from the VM's serial port on the host. The connection is closed when either side disconnects or an error occurs.
Basic per-VM firewall rules. User-defined ACCEPT/DROP/REJECT rules are evaluated
in priority order (lower first) before the default policy. The default policy
per direction is configurable per-VM (accept/drop/reject); when unset it
inherits the host default, which is allow-all inbound and outbound (no change
from prior behaviour). Anti-spoofing (IP filter) protection is always enforced
by the host regardless of user rules.
The maximum number of rules per VM is configurable at the template level and defaults to 20. Any change to the rules queues an asynchronous re-apply of the full firewall ruleset on the host.
FirewallRule type
{
id: number;
priority: number; // evaluation order, lower first
direction: "inbound" | "outbound";
protocol: "any" | "tcp" | "udp" | "icmp";
action: "accept" | "drop" | "reject";
src_cidr?: string | null; // optional source CIDR, null = any
dst_port_start?: number | null; // optional inclusive port range start, null = any
dst_port_end?: number | null; // optional inclusive port range end, null = single port
enabled: boolean;
}- GET
/api/v1/vm/{id}/firewall - Auth: Required
- Response:
FirewallRule[]
- POST
/api/v1/vm/{id}/firewall - Auth: Required
- Body:
{ priority?: number, direction, protocol, action, src_cidr?, dst_port_start?, dst_port_end?, enabled? } - Response:
FirewallRule - Description: Creates a rule and queues a firewall re-apply. Fails if the per-VM rule limit is reached, or if
src_cidr/port range are invalid (ports 1–65535,dst_port_start <= dst_port_end).
- PATCH
/api/v1/vm/{id}/firewall/{rule_id} - Auth: Required
- Body: Partial
FirewallRulefields (all optional). Sendsrc_cidr: null/dst_port_*: nullto clear a field to "any". - Response:
FirewallRule
- DELETE
/api/v1/vm/{id}/firewall/{rule_id} - Auth: Required
- Response:
null
FirewallPolicy type
{
policy_in?: "accept" | "drop" | "reject" | null; // null = inherit host default (allow-all)
policy_out?: "accept" | "drop" | "reject" | null; // null = inherit host default (allow-all)
}- GET
/api/v1/vm/{id}/firewall/policy - Auth: Required
- Response:
FirewallPolicy
- PATCH
/api/v1/vm/{id}/firewall/policy - Auth: Required
- Body:
{ policy_in?, policy_out? }. Omit a field to leave it unchanged, sendnullto reset it to the host default, or a value ("accept"|"drop"|"reject") to set it explicitly. - Response:
FirewallPolicy - Description: Sets the VM's default inbound/outbound policy and queues a firewall re-apply.
- GET
/api/v1/vm/templates - Auth: None
- Response:
{
templates: VmTemplate[];
custom_template?: CustomTemplateParams[];
}- GET
/api/v1/image - Auth: None
- Response:
VmOsImage[]
- POST
/api/v1/vm/custom-template/price - Auth: None
- Body:
CustomVmRequest - Response:
Price
- GET
/api/v1/payment/methods - Auth: None
- Response:
PaymentMethod[]
- GET
/api/v1/vm/{id}/renew?method={payment_method}&intervals={count} - Auth: Required
- Query Params:
method: Optional payment method ('lightning' | 'revolut' | 'paypal' | 'nwc')intervals: Optional number of billing intervals to renew (default: 1). For example, if the VM has a monthly billing cycle,intervals=3would generate a payment for 3 months.
- Response:
VmPayment - Description: Generates a payment invoice to extend the VM's expiration. The payment amount is calculated based on the VM's cost plan and the number of intervals requested. If
method=nwcis specified and the user has a valid NWC connection string configured, the payment will be automatically processed via Nostr Wallet Connect.
- GET
/api/v1/payment/{payment_id} - Auth: Required
- Response:
VmPayment
- GET
/api/v1/vm/{id}/payments?limit={limit}&offset={offset} - Auth: Required
- Query Params:
limit: Optional (default: 50, max: 100)offset: Optional (default: 0)
- Response: Paginated list of VM payments
// Returns: PaginatedResponse<VmPayment>- GET
/api/v1/payment/{payment_id}/invoice?auth={base64_auth} - Auth: Query parameter
- Response: PDF file (Content-Type: text/html)
- GET
/api/v1/subscriptions?limit={limit}&offset={offset} - Auth: Required
- Query Params:
limit: Optional (default: 50, max: 100)offset: Optional (default: 0)
- Response: Paginated list of subscriptions with embedded line items
interface PaginatedResponse<T> {
data: T[];
total: number;
limit: number;
offset: number;
}
// Returns: PaginatedResponse<Subscription>- POST
/api/v1/subscriptions - Auth: Required
- Body:
CreateSubscriptionRequest - Response:
Subscription - Description: Creates a new subscription with one or more line items. The subscription is created in an inactive state. Resources (IP ranges, ASNs, etc.) are not allocated until the first payment is made via the renewal endpoint. After payment confirmation, resources are allocated and the subscription becomes active.
interface CreateSubscriptionRequest {
name?: string; // Display name for the subscription
description?: string; // Optional description
currency?: string; // Currency code (default: 'USD'): 'USD', 'EUR', 'BTC', etc.
auto_renewal_enabled?: boolean; // Enable auto-renewal (default: true)
line_items: CreateSubscriptionLineItemRequest[]; // At least one required
}
// Line item request - tagged union based on service type
type CreateSubscriptionLineItemRequest =
| { type: 'ip_range'; ip_space_pricing_id: number }
| { type: 'asn_sponsoring'; asn: number } // Not yet implemented
| { type: 'dns_hosting'; domain: string }; // Not yet implementedWorkflow:
- User creates subscription with line items (this endpoint)
- User generates payment via
GET /api/v1/subscriptions/{id}/renew - User completes payment (Lightning, Revolut, etc.)
- Payment handler allocates resources and activates subscription
- Resources remain active while subscription is paid
Example Request:
const request: CreateSubscriptionRequest = {
name: "My IP Block Subscription",
description: "IPv4 /24 block from RIPE",
currency: "USD",
auto_renewal_enabled: true,
line_items: [
{ type: "ip_range", ip_space_pricing_id: 5 }
]
};
const response = await fetch('/api/v1/subscriptions', {
method: 'POST',
headers: {
'Authorization': nip98AuthHeader,
'Content-Type': 'application/json'
},
body: JSON.stringify(request)
});
const result: ApiResponse<Subscription> = await response.json();
// result.data.is_active will be false until payment is made- GET
/api/v1/subscriptions/{id} - Auth: Required
- Response:
Subscription(includes all line items)
- PATCH
/api/v1/subscriptions/{id} - Auth: Required (must own the subscription)
- Body:
auto_renewal_enabled: Optional boolean — enable/disable automatic renewal
- Response: Updated
Subscription - Description: Modifies user-editable fields on an existing subscription. Only fields present in the body are changed. Currently limited to toggling
auto_renewal_enabled.
- GET
/api/v1/subscriptions/{id}/renew?method={payment_method} - Auth: Required
- Query Params:
method: Optional payment method ('lightning'|'revolut'|'paypal'|'stripe'). Defaults to'lightning'
- Response:
SubscriptionPayment - Description: Generates a payment invoice to renew/extend the subscription. For the first payment, the amount includes setup fees plus the monthly recurring cost. For subsequent renewals, only the monthly recurring cost is charged. After payment is confirmed, resources (IP ranges, etc.) are allocated and the subscription is activated.
- GET
/api/v1/subscriptions/{subscription_id}/payments?limit={limit}&offset={offset} - Auth: Required
- Query Params:
limit: Optional (default: 50, max: 100)offset: Optional (default: 0)
- Response: Paginated list of payments for a specific subscription
// Returns: PaginatedResponse<SubscriptionPayment>Browse the additional IP space (extra subnets) available for purchase, with pricing. These endpoints are public (no auth) and only return spaces that are available and not reserved.
- GET
/api/v1/ip_space?limit={limit}&offset={offset} - Auth: None
- Query Params:
limit(optional, default 50, max 100),offset(optional, default 0) - Response:
PaginatedResponse<AvailableIpSpace>
- GET
/api/v1/ip_space/{id} - Auth: None
- Response:
AvailableIpSpace - Error: Returns an error if the space is not available or is reserved.
Types:
interface AvailableIpSpace {
id: number;
min_prefix_size: number; // Smallest allocatable prefix (e.g. 29)
max_prefix_size: number; // Largest allocatable prefix (e.g. 24)
registry: "ARIN" | "RIPE" | "APNIC" | "LACNIC" | "AFRINIC";
ip_version: "ipv4" | "ipv6";
pricing: IpSpacePricing[]; // Pricing per allocatable prefix size
}
interface IpSpacePricing {
id: number;
prefix_size: number;
price: Price; // Recurring price in the base currency
setup_fee: Price; // One-time setup fee in the base currency
other_price: Price[]; // Same recurring price in alternative currencies
other_setup_fee: Price[]; // Same setup fee in alternative currencies
}Users can enroll in the referral program to earn payouts when others sign up using their code.
Commission rate fields — how they relate
The referral program pays a commission = a percentage of each referred VM's first payment. Three separate rate fields appear across these endpoints; they are easy to confuse, so read this first:
| Field | Where | Meaning |
|---|---|---|
referral_rate |
Referral |
The per-referrer override, whole %. This is admin-controlled and is null for most referrers. null means “no override — fall back to the company default”. It is not the rate you earn on its own. |
effective_referral_rate |
Referral |
The rate that currently applies to you for display, whole %: the referral_rate override when set, otherwise the default (primary) company's rate. Use this to show “your commission rate”. |
effective_rate |
ReferralUsage (per VM) |
The rate that was actually applied to one referred VM's first payment, whole %. Resolved against that VM's own company, so it can differ per referred VM. |
effective_referral_rateis a headline/default for the UI. The amount actually earned on a given referral is always computed per referred VM (ReferralUsage.effective_rate), because each referred VM may belong to a different company with its own default rate.
- POST
/api/v1/referral - Auth: Required
- Body:
interface ReferralSignupRequest {
lightning_address?: string; // Lightning address for payouts (required when mode is "lightning_address")
mode?: "lightning_address" | "nwc"; // Payout method; defaults to "lightning_address"
}- Response:
Referral - Error: Returns error if already enrolled; if
modeislightning_address(or omitted) without a resolvablelightning_address; or ifmodeisnwcbut no NWC connection is configured on the account.account_creditis a defined-but-unimplemented mode and is rejected.
- GET
/api/v1/referral - Auth: Required
- Response:
ReferralState - Error:
404if not enrolled
- PATCH
/api/v1/referral - Auth: Required
- Body:
interface ReferralPatchRequest {
lightning_address?: string | null; // Set (string) or clear (null) the lightning address; omit to leave unchanged
mode?: "lightning_address" | "nwc"; // Change payout method; omit to leave unchanged
}- Response:
Referral - Note:
referral_rate(the commission override) is admin-controlled and cannot be set through this endpoint.
- DELETE
/api/v1/referral - Auth: Required
- Response: empty (
nulldata) - Error:
409while a payout is still pending, or when paid payout history exists (retained for accounting).
- GET
/api/v1/referral/usage?limit={limit}&offset={offset} - Auth: Required
- Query Params:
limit(optional, default 50, max 100),offset(optional, default 0) - Response:
PaginatedResponse<ReferralUsage>— one row per referred VM that made a first payment (most recent first) - Error:
404if not enrolled
Response Types:
interface Referral {
code: string; // 8-character base63 referral code to share
lightning_address?: string; // Lightning address for payouts (used when mode is "lightning_address")
mode: "lightning_address" | "nwc" | "account_credit"; // Payout method
referral_rate: number | null; // Per-referrer commission override (whole %), admin-controlled; null = no override, use company default. NOT the rate you earn by itself.
effective_referral_rate: number; // The commission rate that currently applies to you (whole %): the override if set, else the default company's rate. Use this for display.
created: string; // ISO 8601 datetime
}
interface ReferralEarning {
currency: string; // Currency code (e.g. "EUR", "BTC")
amount: number; // Total commission earned in this currency (smallest currency unit) = sum over referred VMs of (first payment * effective_rate%)
}
interface ReferralPayout {
id: number;
amount: number;
currency: string;
created: string; // ISO 8601 datetime
is_paid: boolean;
invoice?: string; // BOLT11 lightning invoice
pre_image?: string; // Payment preimage (hex), present once the payout has settled
}
interface ReferralUsage {
// Note: the referred VM's id is intentionally NOT exposed, so a referrer
// cannot map commission back to specific customers' VMs.
created: string; // ISO 8601 datetime of that VM's first paid payment
amount: number; // The referred VM's first payment amount (smallest currency unit)
currency: string; // Currency of the payment / commission
effective_rate: number;// Rate actually applied to THIS referred VM (whole %); resolved against the VM's own company
commission: number; // Commission earned from this VM = amount * effective_rate% (smallest currency unit)
}
interface ReferralState extends Referral {
earned: ReferralEarning[]; // Per-currency breakdown of commission earned
payouts: ReferralPayout[]; // Complete payout history (most recent first)
referrals_success: number; // Number of referred VMs that made at least one payment
referrals_failed: number; // Number of referred VMs that never paid
}- GET
/api/v1/vm/{id}/time-series - Auth: Required
- Response:
TimeSeriesData[]
- GET
/api/v1/vm/{id}/history?limit={limit}&offset={offset} - Auth: Required
- Query Params:
limit: Optional number of records to returnoffset: Optional offset for pagination
- Response:
VmHistory[]
- GET
/.well-known/lnurlp/{vm_id} - Auth: None
- Response: LNURL PayResponse
- GET
/api/v1/vm/{id}/renew-lnurlp?amount={millisats} - Auth: None
- Query Params:
amount: Amount in millisatoshis (minimum 1000)
- Response: Lightning Network invoice
Manage NIP-05 identity domains and their handles. All endpoints require NIP-98 authentication and only operate on domains owned by the caller.
Data types:
interface NostrDomain {
id: number;
name: string; // domain name, e.g. "example.com"
enabled: boolean; // activated by an operator after DNS is configured
handles: number; // number of handles registered under this domain
created: string; // ISO 8601 timestamp
relays: string[]; // relay hints advertised for the domain
}
interface NostrDomainHandle {
id: number;
domain_id: number;
handle: string; // the local part, e.g. "alice" for alice@example.com
pubkey: string; // 32-byte public key, hex-encoded
created: string; // ISO 8601 timestamp
relays: string[]; // relay hints for this handle
}- GET
/api/v1/nostr/domain - Auth: NIP-98
- Response:
{ "domains": NostrDomain[], "cname": string }—cnameis the target hostname to point domain DNS records at.
- POST
/api/v1/nostr/domain - Auth: NIP-98
- Body:
{ "name": string }— the domain name to register - Notes: The domain is created disabled with an activation hash; an operator enables it once DNS/CNAME is configured.
- Response:
NostrDomain
- GET
/api/v1/nostr/domain/{dom}/handle - Auth: NIP-98
- Path Params:
dom— the domain id - Notes: Returns an error if the domain is not owned by the caller.
- Response:
NostrDomainHandle[]
- POST
/api/v1/nostr/domain/{dom}/handle - Auth: NIP-98
- Path Params:
dom— the domain id - Body:
{ "name": string, "pubkey": string }—nameis the handle (local part);pubkeyis a 32-byte public key, hex-encoded - Notes: Returns an error if the domain is not owned by the caller or if the public key is not valid 32-byte hex.
- Response:
NostrDomainHandle
- DELETE
/api/v1/nostr/domain/{dom}/handle/{handle} - Auth: NIP-98
- Path Params:
dom— the domain id;handle— the handle id - Notes: Returns an error if the domain is not owned by the caller.
- Response:
null
- GET
/api/v1/legal/sponsoring-lir-agreement?data={base64url_json} - Auth: None
- Query Params:
data: base64url-encoded JSON of the agreement data
- Response: Rendered HTML agreement document
- Notes: Rejects data that includes a cryptographic proof (use the signed endpoint for that)
- GET
/api/v1/legal/sponsoring-lir-agreement/from-subscription/{subscription_id} - Auth: NIP-98
- Response:
SignedAgreementUrlResponse— a cryptographically signed LIR agreement for one of the caller's own subscriptions. Provider/end-user details are populated from company and user billing data. Returns an error if the subscription does not belong to the caller.
All endpoints return errors in the following format:
interface ApiError {
error: string;
}Common HTTP status codes:
200: Success400: Bad Request (validation errors)401: Unauthorized (invalid or missing authentication)402: Payment Required (e.g. acting on an expired VM that must be renewed first)403: Forbidden (accessing a resource you don't own, or insufficient permissions)404: Not Found (missing resource, or a nested resource that doesn't belong to the parent in the path)409: Conflict (the resource's current state conflicts with the request, e.g. an already-deleted VM or an already-completed payment)500: Internal Server Error501: Not Implemented (feature not yet available)
Rate limiting information is not specified in the current API. Implement appropriate client-side throttling based on usage patterns.
// API response wrapper
interface ApiResponse<T> {
data: T;
}
interface ApiError {
error: string;
}
// Helper for API calls
type ApiResult<T> = ApiResponse<T> | ApiError;
// Type guard for error responses
function isApiError(response: any): response is ApiError {
return 'error' in response;
}
// Type guard for success responses
function isApiSuccess<T>(response: ApiResult<T>): response is ApiResponse<T> {
return 'data' in response;
}- Authentication: All authenticated endpoints require NIP-98 Nostr event authentication
- Date Formats: All dates are in ISO 8601 format
- Currency Units: Amounts are returned as
Priceobjects withcurrencyandamountfields. Theamountis au64integer in the smallest currency unit (e.g., cents for EUR/USD, millisats for BTC). To display human-readable values, divide by the currency's decimal factor (100 for most fiat, 1000 for BTC millisats to sats) - Memory/Disk Units: All memory and disk sizes are in bytes
- VM States: VM states are string enums representing current operational status
- Error Handling: Always check for error responses before accessing data
- Pagination: Some endpoints support optional pagination with
limitandoffsetparameters - Subscriptions: Subscription responses include all line items embedded. Subscriptions are created inactive and only become active after the first payment is completed.
- Subscription Billing: Subscriptions use monthly billing cycles. The first payment includes setup fees plus the monthly recurring cost. Subsequent renewals only charge the monthly recurring cost.
- Setup Fees: Individual line items can have one-time setup fees that are charged only on initial purchase.
- Upgrade Eligibility: All VMs can be upgraded, including both standard template VMs and custom template VMs. For standard template VMs, upgrades transition them to custom templates. For custom template VMs, upgrades modify the existing custom template specifications.
- Pro-rated Billing: Upgrade costs are calculated based on the remaining time until VM expiration. The cost represents the difference between current and new specifications, pro-rated for the remaining billing period. The system calculates: (new_rate_per_second * seconds_remaining) - (old_rate_per_second * seconds_remaining). The discount field shows the value of remaining time at the old rate. The system respects the actual billing interval of the cost plan (daily, monthly, yearly) rather than assuming monthly billing.
- Billing Interval Handling:
- Standard template VMs: Use their cost plan's actual interval (interval_type and interval_amount)
- Custom template VMs: Always use monthly billing for pro-rating calculations
- Examples: A cost plan with interval_type="day" and interval_amount=7 bills every 7 days
- Upgrade Payment Flow:
- First, get a quote using
/api/v1/vm/{id}/upgrade/quote - Then, create an upgrade payment using
/api/v1/vm/{id}/upgrade - Complete the payment (Lightning Network, Revolut, PayPal, or Stripe)
- The upgrade is automatically applied after payment confirmation
- VM Restart: Running VMs are automatically stopped, upgraded, and restarted to apply hardware changes
- First, get a quote using
- Specification Requirements: All upgrade values must be greater than or equal to current values (no downgrades allowed)
- Minimum Billing: Even very short upgrade periods (e.g., VMs expiring soon) have a minimum billing of 1 hour
- Payment Method Support: Upgrades support multiple payment methods (Lightning Network, Revolut, PayPal, Stripe) specified via the optional
methodquery parameter
// Step 1: Get upgrade quote
const upgradeRequest: VmUpgradeRequest = {
cpu: 4, // Upgrade from 2 to 4 CPUs
memory: 4 * 1024 * 1024 * 1024, // 4GB in bytes
disk: 120 * 1024 * 1024 * 1024 // 120GB in bytes
};
// Optional: specify payment method (defaults to 'lightning')
const paymentMethod = 'revolut'; // or 'lightning' or 'paypal'
const quoteResponse = await fetch(`/api/v1/vm/123/upgrade/quote?method=${paymentMethod}`, {
method: 'POST',
headers: authHeaders, // NIP-98 authentication
body: JSON.stringify(upgradeRequest)
});
const quote: ApiResponse<VmUpgradeQuote> = await quoteResponse.json();
console.log(`Upgrade cost: ${quote.data.cost_difference.amount} ${quote.data.cost_difference.currency}`);
console.log(`New monthly cost: ${quote.data.new_renewal_cost.amount} ${quote.data.new_renewal_cost.currency}`);
console.log(`Discount applied: ${quote.data.discount.amount} ${quote.data.discount.currency}`);
// Step 2: Create upgrade payment if user accepts the quote (using same payment method)
// Note: The VM will be restarted automatically after payment to apply hardware changes
const paymentResponse = await fetch(`/api/v1/vm/123/upgrade?method=${paymentMethod}`, {
method: 'POST',
headers: authHeaders,
body: JSON.stringify(upgradeRequest)
});
const payment: ApiResponse<VmPayment> = await paymentResponse.json();
// Step 3: Complete payment based on method
// For Lightning Network: payment.data.data.lightning contains the invoice string
// For Revolut: payment.data.data.revolut.token contains the payment token
// After payment confirmation, the upgrade is automatically appliedAuthentication: None (Public endpoint)
Description: Submit a contact form message to the administrators. This endpoint is rate-limited and requires Cloudflare Turnstile verification.
Request Body:
interface ContactFormRequest {
subject: string; // Required: Message subject
message: string; // Required: Message content
email: string; // Required: Sender's email address
name: string; // Required: Sender's name
user_pubkey?: string; // Optional: User's Nostr public key (npub or hex)
timestamp: string; // Required: ISO 8601 timestamp of submission
turnstile_token: string; // Required: Cloudflare Turnstile verification token
}Response:
interface ContactFormResponse {
data: null;
}Error Responses:
"Subject is required"- Subject field is empty"Message is required"- Message field is empty"Name is required"- Name field is empty"Email is required"- Email field is empty"Invalid email address"- Email format is invalid"Captcha verification failed"- Turnstile token is invalid or expired"Failed to verify captcha"- Server error during Turnstile verification"Captcha not configured"- Server is not configured with Turnstile"Email notifications are not configured"- Server SMTP is not configured"Admin notifications are not configured"- No admin user configured"Failed to send notification"- Failed to queue the notification
Example Request:
const contactForm: ContactFormRequest = {
subject: "Question about VM hosting",
message: "I would like to know more about your VM hosting plans...",
email: "user@example.com",
name: "John Doe",
user_pubkey: "npub1xyz...", // Optional
timestamp: new Date().toISOString(),
turnstile_token: "0.ABC123..." // From Cloudflare Turnstile widget
};
const response = await fetch('/api/v1/contact', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(contactForm)
});
const result: ApiResponse<null> = await response.json();
if (result.error) {
console.error('Contact form submission failed:', result.error);
} else {
console.log('Contact form submitted successfully');
}Notes:
- This endpoint does not require authentication, making it accessible to all users
- All fields except
user_pubkeyare required and will be validated - The Turnstile token must be obtained from the Cloudflare Turnstile widget on the frontend
- The message will be sent to the configured admin email address
- Email addresses are validated with basic format checking (contains @ and .)
- The admin will receive an email containing all the form data including a reply-to address
IP Space management allows users to browse and purchase IP address blocks. For security reasons, the actual CIDR blocks are not exposed in the public API until after purchase.
type InternetRegistry = 'arin' | 'ripe' | 'apnic' | 'lacnic' | 'afrinic';
interface AvailableIpSpace {
id: number;
min_prefix_size: number; // e.g., 24 (smallest allocation)
max_prefix_size: number; // e.g., 22 (largest allocation)
registry: InternetRegistry;
ip_version: 'ipv4' | 'ipv6'; // IP version of this block
pricing: IpSpacePricing[];
}
interface IpSpacePricing {
id: number;
prefix_size: number; // e.g., 24 for /24
price: Price; // Base price in original currency
setup_fee: Price; // Setup fee in original currency
other_price: Price[]; // Prices converted to alternative currencies
other_setup_fee: Price[]; // Setup fees converted to alternative currencies
}
// Note: uses the same Price type as the rest of the API (smallest currency units, uppercase currency codes)
interface IpRangeSubscription {
id: number;
cidr: string; // The allocated IP range e.g., "192.168.1.0/24"
is_active: boolean;
started_at: string; // ISO 8601 datetime
ended_at?: string; // ISO 8601 datetime
parent_cidr: string; // The IP space block this was allocated from
}
interface AddIpRangeToSubscriptionRequest {
ip_space_pricing_id: number; // The pricing tier to use
}Browse available IP address blocks for purchase.
Endpoint: GET /api/v1/ip_space
Authentication: Not required (public endpoint)
Query Parameters:
limit(optional, number): Maximum number of items to return (default: 50, max: 100)offset(optional, number): Number of items to skip (default: 0)
Response: ApiPaginatedResponse<AvailableIpSpace>
Example Request:
const response = await fetch('/api/v1/ip_space?limit=20&offset=0');
const result: ApiPaginatedResponse<AvailableIpSpace> = await response.json();
if (!result.error) {
result.data.forEach(space => {
console.log(`${space.ip_version.toUpperCase()} block from ${space.registry.toUpperCase()}`);
space.pricing.forEach(price => {
console.log(` /${price.prefix_size}: ${price.price.currency} ${price.price.amount}/month`);
if (price.other_price.length > 0) {
console.log(` Also available in: ${price.other_price.map(p => p.currency).join(', ')}`);
}
});
});
}Notes:
- Only shows IP spaces that are available and not reserved
- The actual CIDR blocks are not exposed in the public API - use
ip_versionto determine IPv4 or IPv6 - Each IP space includes all available pricing tiers with alternative currency conversions
- Base pricing uses the
priceandsetup_feefields - Alternative currencies are available in
other_priceandother_setup_feearrays - Different prefix sizes within the same block can have different prices
Get detailed information about a specific IP space block.
Endpoint: GET /api/v1/ip_space/{id}
Authentication: Not required (public endpoint)
Path Parameters:
id(number): The IP space ID
Response: ApiResponse<AvailableIpSpace>
Error Responses:
"IP space not available"(400 Bad Request) - IP space is not available for purchase404 Not Found- IP space ID does not exist
Example Request:
const response = await fetch('/api/v1/ip_space/1');
const result: ApiResponse<AvailableIpSpace> = await response.json();
if (!result.error) {
console.log(`${result.data.ip_version} block from ${result.data.registry}`);
console.log(`Prefix sizes: /${result.data.min_prefix_size} to /${result.data.max_prefix_size}`);
}List all IP ranges allocated to a specific subscription.
Endpoint: GET /api/v1/subscriptions/{subscription_id}/ip_ranges
Authentication: Required (NIP-98)
Path Parameters:
subscription_id(number): The subscription ID
Query Parameters:
limit(optional, number): Maximum number of items to return (default: 50, max: 100)offset(optional, number): Number of items to skip (default: 0)
Response: ApiPaginatedResponse<IpRangeSubscription>
Error Responses:
"Access denied: not your subscription"(403 Forbidden) - User doesn't own this subscription
Example Request:
const response = await fetch('/api/v1/subscriptions/123/ip_ranges', {
headers: {
'Authorization': nip98AuthHeader,
'Content-Type': 'application/json'
}
});
const result: ApiPaginatedResponse<IpRangeSubscription> = await response.json();
if (!result.error) {
result.data.forEach(ipRange => {
console.log(`${ipRange.cidr} (from ${ipRange.parent_cidr})`);
console.log(`Active: ${ipRange.is_active}`);
});
}Purchase an IP range and add it to an existing subscription.
Endpoint: POST /api/v1/subscriptions/{subscription_id}/ip_ranges
Authentication: Required (NIP-98)
Path Parameters:
subscription_id(number): The subscription ID
Request Body: AddIpRangeToSubscriptionRequest
interface AddIpRangeToSubscriptionRequest {
ip_space_pricing_id: number; // ID of the pricing tier to purchase
}Response: ApiResponse<IpRangeSubscription>
Error Responses:
"Access denied: not your subscription"(403 Forbidden) - User doesn't own this subscription"IP space is not available for allocation"- IP space is no longer available"IP range allocation not yet implemented - please contact support to manually allocate IP ranges"- Feature not yet complete (current status)
Example Request:
const request: AddIpRangeToSubscriptionRequest = {
ip_space_pricing_id: 5 // ID from the pricing list
};
const response = await fetch('/api/v1/subscriptions/123/ip_ranges', {
method: 'POST',
headers: {
'Authorization': nip98AuthHeader,
'Content-Type': 'application/json'
},
body: JSON.stringify(request)
});
const result: ApiResponse<IpRangeSubscription> = await response.json();
if (!result.error) {
console.log(`Allocated: ${result.data.cidr}`);
console.log(`Started: ${result.data.started_at}`);
}Notes:
- IP allocation logic is not yet implemented - this endpoint currently returns an error
- When implemented, the system will:
- Find an available subnet of the requested prefix size
- Check for conflicts with existing allocations
- Create a subscription line item with the monthly and setup fees
- Create the IP range subscription record
- Return the allocated CIDR
- The allocated IP range will be added as a line item to the subscription with recurring billing
- Setup fees are charged once, monthly fees recur with the subscription billing cycle
These endpoints are only available to administrators with appropriate permissions.
Endpoint: GET /api/admin/v1/ip_space
Authentication: Required (Admin with ip_space::view permission)
Query Parameters:
limit(optional, number): Maximum items to return (default: 50, max: 100)offset(optional, number): Items to skip (default: 0)is_available(optional, boolean): Filter by availability statusregistry(optional, number): Filter by registry (0=ARIN, 1=RIPE, 2=APNIC, 3=LACNIC, 4=AFRINIC)
Endpoint: POST /api/admin/v1/ip_space
Authentication: Required (Admin with ip_space::create permission)
Request Body:
interface CreateAvailableIpSpaceRequest {
cidr: string;
min_prefix_size: number;
max_prefix_size: number;
registry: number; // 0=ARIN, 1=RIPE, 2=APNIC, 3=LACNIC, 4=AFRINIC
external_id?: string; // RIR allocation ID
is_available?: boolean; // Default: true
is_reserved?: boolean; // Default: false
metadata?: object; // JSON metadata (routing requirements, etc.)
}Endpoint: PATCH /api/admin/v1/ip_space/{id}
Authentication: Required (Admin with ip_space::update permission)
Endpoint: DELETE /api/admin/v1/ip_space/{id}
Authentication: Required (Admin with ip_space::delete permission)
Error: Returns error if there are active subscriptions using this IP space
List Pricing: GET /api/admin/v1/ip_space/{id}/pricing
Create Pricing: POST /api/admin/v1/ip_space/{id}/pricing
Update Pricing: PATCH /api/admin/v1/ip_space/{space_id}/pricing/{pricing_id}
Delete Pricing: DELETE /api/admin/v1/ip_space/{space_id}/pricing/{pricing_id}
Request Body (Create):
interface CreateIpSpacePricingRequest {
prefix_size: number; // e.g., 24 for /24
price_per_month: number; // In cents/millisats
currency?: string; // Default: "USD"
setup_fee?: number; // Default: 0
}View all subscriptions for a specific IP space.
Endpoint: GET /api/admin/v1/ip_space/{id}/subscriptions
Authentication: Required (Admin with subscriptions::view permission)
Query Parameters:
limit,offset: Paginationuser_id(optional): Filter by useris_active(optional): Filter by active status
This documentation is optimized for LLM code generation and provides all necessary type definitions and endpoint specifications for building TypeScript frontend applications.