All notable changes to the LNVPS APIs are documented in this file.
The format is based on Keep a Changelog.
- DNS is now best-effort during VM provisioning — forward (A/AAAA) and reverse (PTR) DNS records are convenience only and no longer block or roll back a deploy. Previously a reverse-DNS failure (notably OVH rejecting a PTR with a
4xxuntil the forward name resolves — a fatal, non-retried error) tore down the whole pipeline and destroyed the freshly-created VM. Now such failures are logged and the deploy proceeds; the VM keeps its IPs and MAC. Any records that failed to create are reconciled automatically on the periodic VM check (and can still be forced via the existing DNS patch job).
-
Clean up orphaned custom templates — a startup data migration now deletes
vm_custom_templaterows not referenced by any VM. Custom templates are 1:1 with their VM, but historical hard-deletes could leave orphans behind. The migration is idempotent (a no-op once clean). -
Purge historical never-paid soft-deleted VMs — a startup data migration now hard-deletes VMs that were soft-deleted (
deleted = 1) while their subscription was never paid (is_setup = 0), along with theirvm_history,vm_firewall_rule,vm_ip_assignmentand orphaned subscription. Never-paid VMs are purged going forward by the worker, but the worker skips already soft-deleted rows so older ones lingered. Ever-paid VMs are left untouched to preserve payment history. Idempotent (a no-op once clean). -
Data migrations now log their progress — each startup data migration logs when it starts and a summary of what it did (e.g. "purged 3 of 3 never-paid soft-deleted VM(s)"), so no-op runs are no longer silent.
-
Cascade-delete owned child tables — added
ON DELETE CASCADEto pure owned-child tables (SSH keys, passkeys, saved payment methods, referrals/payouts, cached router tunnel/BGP inventory, custom pricing disks). Fixes latent FK-violation failures when deleting a router with cached inventory or a referral with payouts, and simplifies user purges.
-
Transfer a VM to another user (admin) — new
POST /api/admin/v1/vms/{id}/transfer(virtual_machines::update) reassigns a VM to another user account, e.g. for account recovery (issue #178). It atomically moves the VM and its billing subscription to the targetuser_idand clears the old owner's SSH key from the VM. The change is recorded in VM history as a newtransferredaction. Rejects deleted VMs, self-transfers (409) and unknown target users (404). Body:{ user_id, reason? }. -
Change OS during re-install —
PATCH /api/v1/vm/{id}/re-installnow accepts an optional{ image_id }body to switch the VM to a different OS image as part of the re-install (issue #177). When omitted, the VM is reinstalled with its current image (unchanged behaviour). The chosen image must exist and be enabled, and the change is recorded in VM history. -
OS image popularity —
GET /api/v1/imagenow returns apopularityfield for each OS image: the fraction (0.0–1.0) of active VMs currently using that image (issue #70). -
Delete (purge) a user (admin) — new
DELETE /api/admin/v1/users/{id}(users::delete) permanently removes a user and all of their associated data (soft-deleted VMs and their history/IP/firewall rows and 1:1 custom templates, SSH keys, subscriptions and payments, referral records, Nostr domains, passkeys and saved payment methods) in a single transaction. Rejected if the user still has any live (non-deleted) VMs — those must be deleted first so hypervisor resources are released. Admins cannot delete their own account. -
Manage user passkeys (admin) — new admin endpoints to view and revoke a user's WebAuthn passkeys.
GET /api/admin/v1/users/{id}/passkeys(users::view) lists a user's registered passkeys (id, optional devicename, hexcred_id,created,last_used). Credential material is never exposed.DELETE /api/admin/v1/users/{id}/passkeys/{passkey_id}(users::update) revokes a single passkey. Returns404if the passkey doesn't belong to the user, and refuses (400) to remove the last passkey of a passwordless (webauthn) account to avoid locking the user out.GET /api/admin/v1/users/{id}now also returnsaccount_type(nostr|oauth|webauthn) andpasskey_count.
-
Permanently delete VMs (admin) —
DELETE /api/admin/v1/vms/{id}now accepts an optionalpurgeflag in the request body (issue #168).- VMs that have never been paid (their subscription was never set up) are now hard-deleted from the database instead of being soft-deleted, both when an admin deletes them and when the worker's hourly cleanup removes unpaid VMs. Nothing is left behind: the VM row and all related records (history, firewall rules, IP assignments, and the VM's own subscription + line items + payment history) are removed.
purge: truelets a super_admin permanently delete any VM — including VMs with payment history — clearing up all related entities. This is intended for removing test VMs. The flag is rejected with403for non-super-admins (checked before the VM lookup); regular deletes and never-paid purges are unaffected.
-
Import existing host VMs (admin) — new admin endpoints to adopt VMs that exist on a host but aren't tracked in the database (issue #166).
GET /api/admin/v1/hosts/{id}/vms/unmanagedlists VMs present on the host with no matching database record (returns host vmid, mapped database id, name, CPU/memory/disk specs, storage, MAC, running state). The admin service dispatches a discovery job to the worker and reads the reply over a temporary Redis pub/sub channel.POST /api/admin/v1/hosts/{id}/vms/import({ host_vm_id, user_id, reason? }) imports one VM: it is assigned touser_idand billed via the region's custom pricing (required — import fails if the region has none), capturing the VM's current CPU/memory/disk specs into a custom template. Currently supports Proxmox hosts. Returns ajob_id.
-
Per-request OAuth return URL —
GET /api/v1/oauth/{provider}/loginnow accepts an optional?redirect=<url>to override the configuredsuccess-redirectfor that login only (e.g.http://localhost:3000/oauth/completein dev). The URL is validated against a newallowed-redirectsallowlist (localhosthost always allowed;success-redirectalways implicitly allowed) and rejected with400otherwise, then round-tripped through the signedstate. See the OAuth login flow in API_DOCUMENTATION.md for details and aconfig.yamlexample. -
2026-07-18 - Passwordless WebAuthn / passkey login
- New
fetch-based endpointsPOST /api/v1/webauthn/register/start+/register/finish(creates a passwordless account) andPOST /api/v1/webauthn/login/start+/login/finish(usernameless / discoverable login). Eachstartreturns achallengefor the browsernavigator.credentialsAPI plus an opaque signedstate; the matchingfinishposts back thatstatewith the credential and returns a{ token, token_type, expires_in }session response. - Uses the same stateless session JWT as OAuth (
Authorization: Bearer <jwt>), so passkey users reach every existing authenticated endpoint. Configured under a newwebauthnconfig section (rp-id,rp-origin,rp-name); the signing secret lives in the sharedsessionblock (below). - Passkey accounts are stored with a new
account_typeofwebauthnand a synthetic identity (sha256("webauthn\\0{user_handle}"), in a namespace provably disjoint from OAuth). Like OAuth accounts they have no usable Nostr key: NIP-17 DMs, npub display and LIR signing are gated off, andGET /api/v1/accountreportsaccount_type: "webauthn". Credentials are stored in the newuser_webauthn_credentialstable (one account may register several devices). - Add passkeys to existing accounts: authenticated endpoints
GET /api/v1/webauthn/credentials(list),POST /api/v1/webauthn/credentials/start+/finish(register another passkey to the current account — any account type, Nostr/OAuth/passkey), andDELETE /api/v1/webauthn/credentials/{id}(remove one). A later discoverable login with such a passkey resolves back to the same account (by credential id), so its session token carries the account's real identity. A pure passkey account cannot delete its only credential.
- New
-
2026-07-18 - Generic OAuth / OIDC login (Google, GitHub, Facebook, Apple)
- New endpoints
GET /api/v1/oauth/{provider}/login(redirects to the provider) andGET/POST /api/v1/oauth/{provider}/callback(exchanges the authorization code, resolves/creates the user and issues a session token). Providers are configured under the newoauthconfig section, each with atypeofgoogle,github,facebook,apple, or genericoidc. - Built-in flavors handle each provider's quirks: GitHub's
User-Agentrequirement and numericidsubject, Facebook's Graphmeendpoint, and Sign in with Apple'sid_token-based subject, dynamically-signed ES256 client secret, andform_post(POST) callback. - After a successful login the API issues a stateless session JWT. It is accepted on every existing authenticated endpoint via
Authorization: Bearer <jwt>, alongside the existingAuthorization: Nostr <event>(NIP-98) scheme. The JWT signing secret and lifetime live in a shared top-levelsessionconfig block (session.secret,session.ttl) used by both OAuth and WebAuthn — required whenever either login method is enabled. - On first login the provider's email is synced into the account (marked verified when the provider asserts it) and email notifications are enabled by default, since OAuth accounts have no NIP-17 channel. GitHub's primary verified address is fetched from
/user/emails; Apple's email comes from theid_token. The sync is non-destructive — a user's later email edits are not overwritten — and best-effort (a sync failure never blocks login). - OAuth accounts are stored with a new
account_typeofoauthand a synthetic identity (sha256("{provider}:{subject}")) in place of a Nostr pubkey. Nostr-only features (NIP-17 DMs, npub display, LIR agreement signing) are gated to native Nostr accounts. GET /api/v1/accountnow returnsaccount_type(nostr|oauth, read-only) so the frontend can hide Nostr-only UI for OAuth users.PATCH /api/v1/accountrejects enablingcontact_nip17for OAuth accounts (their pubkey is not a usable Nostr key).
- New endpoints
- 2026-07-18 - Drop npub from invoices and VM-created notifications
- VM-created notifications (user + admin) no longer include the
NPUB:line — it was noise and meaningless for OAuth accounts. - The rendered invoice replaces the
Nostr Pubkeyline with the accountEmail(shown only when set), which is a universal identifier across Nostr and OAuth accounts.
- VM-created notifications (user + admin) no longer include the
-
Passkey usernameless login failing on non-synced authenticators — passkey registration now always creates a discoverable (resident-key) credential (
residentKey: "required",userVerification: "required"), so "Sign in with a passkey" reliably works on security keys and Windows Hello, not just synced platform passkeys (iCloud Keychain / Google Password Manager). Previously registration went through webauthn-rs' high-level flow which hardcodesresidentKey: "discouraged", causing those authenticators to store a non-discoverable credential that usernameless login (start_discoverable_authentication) couldn't find (NotAllowedError). Applies to both new-account registration and adding a passkey to an existing account. New optional configwebauthn.require-resident-key(defaulttrue) can relax this for a misbehaving authenticator. Trade-off: a security key with no free resident-key slots now fails clearly at registration instead of silently registering an unusable-for-login credential. -
2026-07-18 - Referral commission rate not visible to referrers (user API)
GET/POST/PATCH /api/v1/referralonly returnedreferral_rate, the per-referrer override, which isnullfor most referrers (it's admin-set). A referrer with an unset override therefore saw no commission rate even though the default company rate applied. The response now also includeseffective_referral_rate(whole %): the rate that currently applies — the override when set, otherwise the default (primary) company'sreferral_rate.
-
2026-07-18 - Admin referral code renaming
PATCH /api/admin/v1/referrals/{id}now accepts an optionalcodeto rename a referral enrollment's code. Renaming cascades: every existing VM that recorded the old code at ordering time (vm.ref_code) is re-pointed to the new code in the same transaction, so all prior usage/earnings stay attributed to that referrer (e.g. when assigning a user a custom vanity code without losing their existing referrals). This also relinks a user's enrollment to a historicalvm.ref_codethat was tracked before the user auto-generated their own code. The new code must be non-empty and not already in use by another referral (409-style validation error otherwise). Requiresreferral::update.
-
2026-07-18 - Referral program: leave + per-VM usage (user API)
DELETE /api/v1/referrallets a referrer leave the program. Blocked (409) while a payout is pending, or when paid payout history exists (retained for accounting).GET /api/v1/referral/usagereturns a paginated per-referred-VM breakdown (query paramslimitdefault 50/max 100,offsetdefault 0; response is{ data, total, limit, offset }). Each row hascreated,amount(the VM's first payment),currency,effective_rate, andcommission. The referred VM's id is intentionally not exposed so a referrer cannot map commission back to specific customers' VMs.
-
2026-07-18 - Automated referral commission payouts
- A new opt-in worker job pays referrers their accrued BTC commission over Lightning. For each referrer whose owed commission (earned minus already paid/reserved) clears a configurable threshold, it reserves a payout, fetches a BOLT11 invoice (LNURL-pay for a
lightning_addressreferrer, or NWCmake_invoicefor annwcreferrer), pays it from the node, and records the preimage. Non-BTC (fiat) commission is not auto-paid and is left for manual admin payout. Enabled by adding areferralconfig section (min-payout-sats, default 1000); when omitted, automated payouts are disabled.GET /api/v1/referralpayout records now includepre_image(hex, once settled).
- A new opt-in worker job pays referrers their accrued BTC commission over Lightning. For each referrer whose owed commission (earned minus already paid/reserved) clears a configurable threshold, it reserves a payout, fetches a BOLT11 invoice (LNURL-pay for a
-
2026-07-18 - Admin referral program management
- New admin endpoints under the
referralRBAC resource (granted tosuper_admin):GET /api/admin/v1/referrals(paginated;searchby code substring or 64-char hex pubkey),GET /api/admin/v1/referrals/{id}(referral + per-currency earned commission + payout history + success/failed counts),PATCH /api/admin/v1/referrals/{id}(set/clear the per-referrer commission override),GET/POST /api/admin/v1/referrals/{id}/payouts(list / create a manual payout record), andPATCH /api/admin/v1/referrals/{id}/payouts/{payout_id}(mark paid / set invoice / set preimage for reconciliation). NWC secrets are never exposed.
- New admin endpoints under the
-
2026-07-18 - Flexible referral payout mode (replaces
use_nwc)- The referral payout method is now an extensible
modeenum instead of theuse_nwcboolean.GET /api/v1/referralreturnsmode(lightning_address|nwc|account_credit) instead ofuse_nwc.POST/PATCH /api/v1/referralacceptmodeinstead ofuse_nwc:lightning_address(default) requires a resolvablelightning_address;nwcrequires a configured NWC connection;account_creditis reserved for a future account-balance payout and is currently rejected. Existing enrollments migrateuse_nwc = true→nwc, otherwiselightning_address.
- The referral payout method is now an extensible
-
2026-07-18 - Referral commission rate (percentage of first payment)
- The referral program now pays a commission = a percentage of each referred VM's first payment. The effective rate is per-referrer with a company default:
company.referral_rate(new, default0) applies unless the referrer has an override. Admin companyPOST/PATCH /api/admin/v1/companiesacceptreferral_rate(whole %,>= 0) and GET/list responses expose it. GET /api/v1/referralnow returnsreferral_rate(the per-referrer override,null= use company default; read-only — set by admins) and itsearnedamounts are now the commission (payment * effective_rate%) rather than the full first payment.GET /api/admin/v1/reports/referral-usage/time-seriesrows gaineffective_rateandcommission.
- The referral program now pays a commission = a percentage of each referred VM's first payment. The effective rate is per-referrer with a company default:
-
2026-07-18 - OSS (One-Stop Shop) VAT report
GET /api/admin/v1/reports/ossaggregates cross-border EU B2C sales (tax_treatment = oss_b2c) by filing period and destination member state for transcription onto an OSS VAT return. Query params:start_date,end_date(YYYY-MM-DD), optionalcompany_id(0/omitted = all), andperiod=quarter(default, calendar Q1-Q4) |bimonthly(two-month buckets B1-B6). Rows are keyed by(period, company, destination country, VAT rate)and expressed in each seller company's base currency using the exchange rate frozen on each payment. Only paid payments are counted. Requiresanalytics::view.
-
2026-07-18 - VIES trader name/address verification on tax ID
PATCH /api/v1/accountnow sends the customer's billing name/address to VIES alongside the VAT number so VIES can match them against the registered values. An invalid VAT number is still a hard error; name/address mismatches are non-fatal. The endpoint response body changed fromnullto{ "warnings": [string] }(awarningsarray is present only when VIES reports a confirmed field mismatch, e.g. name or address; the account is still saved).
-
2026-07-18 - Admin user list gains region/role/has_vms filters
GET /api/admin/v1/usersaccepts optional query params to narrow the list (combined with AND, alongside the existingsearchpubkey filter):region_id(only users with at least one non-deleted VM whose host is in that region),role(super_admin/admin/read_only— only users with an active assignment to that admin role), andhas_vms(true/false— filter by whether the user has any non-deleted VMs). Requiresusers::view.
-
2026-07-18 - Toggle subscription auto-renewal (user-facing)
PATCH /api/v1/subscriptions/{id}accepts optionalauto_renewal_enabledto enable/disable automatic renewal on an existing subscription. Ownership is enforced (NIP-98). Returns the updatedApiSubscription. Previously auto-renewal could only be set at creation time (POST /api/v1/subscriptions) or via the admin API.
-
2026-07-18 - Pay VM upgrades with saved payment methods
POST /api/v1/vm/{id}/upgradenow acceptsmethod=nwcandmethod=saved(with optionalpayment_method_id), matching the renewal endpoint. Saved methods are collected on the spot: NWC pays the Lightning invoice via the user's saved wallet, andsavedcharges a saved Revolut card off-session. For these off-session methods the request briefly waits (~10s) for settlement, returning theVmPaymentasis_paid: trueif it settled in time, otherwise pending (settles asynchronously). An immediate charge failure returns an error and leaves the payment unpaid.GET /api/v1/subscriptions/{id}/renewaccepts the samemethod=nwcfor parity with VM renewals/upgrades.
-
2026-07-18 - VM upgrades now include VAT and processing fee
- Upgrade payments (
POST /api/v1/vm/{id}/upgrade) are now charged VAT on the net upgrade cost plus the payment processing fee (previously both were0).VmUpgradeQuote(POST /api/v1/vm/{id}/upgrade/quote) gainstaxandprocessing_feefields so the full upgrade total can be shown up-front.
- Upgrade payments (
-
2026-07-18 - VM status exposes its subscription id
GET /api/v1/vmandGET /api/v1/vm/{id}responses include a read-onlysubscription_id. A VM is billed by a subscription underneath, so clients can renew a VM by renewing its subscription (GET /api/v1/subscriptions/{id}/renew) and unify VM/subscription payment handling.null/omitted for VMs that were never paid.
-
2026-07-18 - VM host regions expose their seller company
VmHostRegion(embedded in template/VM responses, e.g.GET /api/v1/vms,GET /api/v1/vm/templates) now includes a read-onlycompany_id. Match it againstaccount.tax[].company_idto display the VAT rate that applies to a specific VM up-front.
-
2026-07-15 - Admin API exposes IP-derived geolocation evidence on users
AdminUserInfo(returned byGET /api/admin/v1/users,.../users/{id},.../users/by-email) now includes read-onlygeo_country_code(ISO 3166-1 alpha-3, resolved from the client IP as independent VAT place-of-supply evidence),geo_ip, andgeo_updated(ISO 8601).PATCH /api/admin/v1/users/{id}accepts optionalgeo_country_code(validated alpha-3; empty string clears) andgeo_ip(empty string clears). Editing either bumpsgeo_updatedto the edit time. Requiresusers::update.
-
2026-07-15 - VM status now includes the actual deletion date
GET /api/v1/vmsandGET /api/v1/vms/{id}responses include a read-onlydeleting_on(ISO 8601) — the date the VM will be deleted if not renewed. This isexpiresplus the subscription's grace period, which is dynamic (tiered by subscription age), so the field reflects the real deletion date rather than a fixed offset. It isnull/omitted for VMs with no expiry (never paid).
-
2026-07-17 - Account info now includes the applicable tax rate
GET /api/v1/accountreturns a read-onlytaxarray with one entry per seller company:{company_id, company_name, rate, country_code, treatment}. The rate is determined from the user's current billing information (VAT number, declared country, IP-derived country) using the same EU place-of-supply rules as invoicing, so frontends can show the expected VAT up-front. The field is ignored on PATCH.
-
2026-07-18 - New VMs default to auto-renewal enabled
- Ordering a VM (standard or custom template) now creates its subscription with
auto_renewal_enabled = trueby default (previouslyfalse), matching the subscription-creation API which already defaulted on. Auto-renewal only actually charges when the user has a saved payment method (NWC wallet or Revolut card); users without one are unaffected and still receive the normal expiry reminders. Users can opt out anytime viaPATCH /api/v1/subscriptions/{id}(auto_renewal_enabled: false).
- Ordering a VM (standard or custom template) now creates its subscription with
-
2026-07-16 - Invoices now show the VAT treatment
GET /api/v1/payment/{id}/invoicerenders the applied VAT rate on the tax line (e.g. "VAT 23% (IRL)"), and prints a legal note for reverse charge ("VAT reverse charged — the recipient is liable to account for VAT (Article 196, Council Directive 2006/112/EC)") and out-of-scope ("Outside the scope of EU VAT.") supplies. Seller and customer VAT numbers were already shown.
-
2026-07-16 - VAT determination frozen on every payment (OSS filing / audit)
- Each
subscription_paymentnow stores the VAT determination made at sale time: a per-line-itemtax_breakdown(JSON array of{net, tax, rate, country_code, treatment}) as the authoritative record, a uniform summary (tax_rate,tax_country_code,tax_treatment, left NULL when the payment mixes rates/treatments across line items), and the customertax_evidence(declared country, IP-derived country, VAT number). Admin time-series reports expose these per payment. - The breakdown is per-line-item so a payment whose lines resolve to different sellers (e.g. reverse charge on one, domestic VAT on another) is recorded losslessly rather than collapsed to one rate.
- Each
-
2026-07-16 - Retired the defunct
vm_paymenttablevm_paymentis dropped; all payments live insubscription_payment(the public API already read from it). The startup backfill's payment-copy phase (Phase 2) and allvm_paymentmodels/queries/DTO conversions are removed. TheApiVmPaymentresponse shape and thevm_paymentRBAC resource are unchanged.
-
2026-07-16 - VAT is now charged using EU place-of-supply rules instead of a flat per-country lookup
- EU VAT only, gated on the seller. The seller's country is taken from the company's own VAT number (
tax_id, i.e. its VIES registration) when set, elsecountry_code. Tax is applied only when that country is in the EU VAT area. A non-EU seller (e.g. a US company) charges no tax here; other systems such as US sales tax are not handled. - When the seller is in the EU, the tax charged is determined from the seller's country and the customer's status/location:
- B2B with a stored (VIES-validated) VAT number: same country as seller → domestic VAT; another EU country → reverse charge (0%); outside the EU → out of scope (0%).
- B2C: place of supply is taken from the self-declared country, falling back to the IP-derived country. EU → that country's destination rate (OSS); non-EU → out of scope (0%).
- Undetermined (no customer country evidence): the seller-country rate is applied as a fallback.
- Behaviour change: non-EU customers are now correctly out of scope (0%). Previously any country present in the
tax_rateconfig map was charged its configured rate regardless of EU membership (e.g. a placeholderUSA: 1%). - Config removed: the static
tax-rateconfig map is gone. Standard EU rates for all member states are now fetched at startup and refreshed daily, cached in-memory by a shared cloneableVatClient(formerlyEuVatClient). Until the first successful refresh, VAT falls back to 0%. - New public API in
lnvps_api_common:PricingEngine::determine_tax(full treatment + audit detail),TaxTreatment,TaxDetermination,is_eu_vat_country,vat_number_country_alpha3;VatClientgainsrefresh_rates/rate_for/with_rates.get_tax_for_usernow also takes the sellercompany_id.
- EU VAT only, gated on the seller. The seller's country is taken from the company's own VAT number (
-
2026-07-16 - Cost tracking (P/L groundwork, issue #82)
- Optional, admin-only cost data stored in a new generic
resource_costtable, weakly linked to any resource via(resource_type, resource_id)— no schema change needed to add new cost-bearing resource kinds, and a single resource can hold multiple cost records (e.g. a host's recurring rent plus a one-time hardware investment).GET /api/admin/v1/resource_costs— paginated list; optionalresource_type(vm_host|ip_range) andresource_idfilters (resource_cost::view).GET /api/admin/v1/resource_costs/{id}— fetch one (resource_cost::view).POST /api/admin/v1/resource_costs— create (resource_cost::create).PATCH /api/admin/v1/resource_costs/{id}— update; interval/billing fields use PATCH-clear semantics (omit = unchanged,null= clear) (resource_cost::update).DELETE /api/admin/v1/resource_costs/{id}— remove (resource_cost::delete).
- Each cost record has
cost_type(recurring|one_time),amount(smallest currency units; per-IP forip_rangerecurring),currency, an optional billing interval (interval_amount+interval_type), and optionalbilling_start/billing_enddates (billing_endnull = still active). resource_typesupportsvm_host,ip_range, andgeneric— the latter is not tied to any internal entity and is identified by a free-formlabel(e.g. a colo/transit subscription);resource_idis ignored forgenericcosts.- Cost data is never exposed to end users. Currency conversion between costs and revenues is deferred to a follow-up.
- Adds the
AdminResource::ResourceCost(24) RBAC resource; a migration grants the full permission set to the defaultsuper_adminrole. GET /api/admin/v1/reports/profit-loss— per-period (month/year) profit/loss report netting paid revenue against tracked resource costs. Reported in a single target currency (currencyparam, or the selected company's base currency), so each period is one row. Revenue uses each payment's stored historical exchange rate to value it in its company base currency; costs (no stored rate) use current exchange rates. Recurring costs are normalized per active calendar month,ip_rangeper-IP costs scale by the range's current assigned-IP count, and one-time costs are booked in theirbilling_startperiod. Optionalgroup_by(month|year),company_idandregion_idfilters (currencyrequired whencompany_idomitted). Requiresanalytics::view.
- Optional, admin-only cost data stored in a new generic
-
2026-07-16 - IP geolocation captured as VAT place-of-supply evidence
PATCH /api/v1/accountand both VM order endpoints (POST /api/v1/vm,POST /api/v1/vm/custom-template) now record the client's IP-derived country (ISO 3166-1 alpha-3) alongside the self-declaredcountry_code, stored independently on the user (geo_country_code,geo_ip,geo_updated) so the two signals can be compared when determining EU VAT. Capturing at order time ensures customers who never touch the account API still have a resolved country at purchase. The client IP is read from theX-Forwarded-For/X-Real-IPheaders set by the trusted front proxy.- New optional config key
geoip-database: path to a local MaxMind GeoLite2/GeoIP2 Country.mmdb. When unset, IP geolocation is disabled and no country is recorded. Lookups are local and never leave the host. - Non-routable addresses (private/loopback/link-local/documentation) are skipped without a lookup.
-
2026-07-15 - Admin management of users' saved payment methods
- New admin endpoints to list/inspect/edit/delete the payment methods users save for automatic renewals (NWC connections and off-session Revolut cards). Distinct from the existing
payment_methodsprovider-config endpoints.GET /api/admin/v1/user_payment_methods— paginated list across all users; optionaluser_idfilter (user_payment_method::view).GET /api/admin/v1/user_payment_methods/{id}— fetch one (user_payment_method::view).PATCH /api/admin/v1/user_payment_methods/{id}— setname(nullable),is_default, and/orenabled(user_payment_method::update).DELETE /api/admin/v1/user_payment_methods/{id}— remove a saved method (user_payment_method::delete).
- Responses expose only non-sensitive metadata (provider, label, card brand/last4/expiry, default/enabled) plus a
has_external_customer_idflag; encrypted provider tokens / NWC strings are never returned. - Adds the
AdminResource::UserPaymentMethod(23) RBAC resource; a migration grants the full permission set to the defaultsuper_adminrole.
- New admin endpoints to list/inspect/edit/delete the payment methods users save for automatic renewals (NWC connections and off-session Revolut cards). Distinct from the existing
-
2026-07-15 - Save cards on demand and pay with a saved card
- Card-payment renewal/purchase requests now accept
save_card=trueto explicitly tokenize the entered card as a reusable payment method, independent ofauto_renewal_enabled. Previously a card was only saved when auto-renewal happened to be enabled, so ticking a "save card" checkout box without auto-renewal saved nothing. - Renewal requests now accept
method=savedto charge an already-saved card directly (merchant-initiated, no checkout). An optionalpayment_method_idselects a specific saved card; omitted uses the user's default saved card. - Applies to
POST /api/v1/vm/{id}/renewandPOST /api/v1/subscriptions/{id}/renewvia the shared query params (method,intervals,save_card,payment_method_id).
- Card-payment renewal/purchase requests now accept
-
2026-07-15 -
GET /api/admin/v1/dns_servers/{id}/zones(admin) — list the DNS zones available on a configured DNS server ({ id, name }per zone), for populating forward/reverse zone id pickers on IP ranges. Cloudflare returns its zones; OVH (reverse-only, zoneless) returns an empty list. Requiresdns_server::view. -
2026-07-14 -
DELETE /api/v1/ssh-key/{id}— remove a saved SSH key from the account. Only the key's owner may delete it. -
2026-07-14 -
GET /api/v1/ssh-keynow includes avmsfield on each key: the list of the user's active (non-deleted) VM IDs currently using that SSH key. -
2026-07-14 - VM orders now validate that the supplied
ssh_key_idbelongs to the requesting user (previously any existing key id was accepted). Ordering a VM continues to require a validssh_key_id. -
2026-07-14 - When a VM is deleted its SSH key reference is now cleared (
vm.ssh_key_idis nullable), so an SSH key that was only ever used by now-deleted VMs can be removed viaDELETE /api/v1/ssh-key/{id}instead of failing with a foreign-key error. -
2026-07-14 - Unified saved payment methods + Revolut auto-renewal
- Subscriptions with
auto_renewal_enabledare now renewed automatically by charging the user's default saved payment method, dispatched by provider: Nostr Wallet Connect (Lightning) or a saved Revolut card charged off-session (merchant-initiated). Closes #159. - Saved methods live in a new provider-agnostic
user_payment_methodtable (one-to-many per user, withis_defaultandenabled). NWC and Revolut are both modelled as payment methods, so users can keep several and choose which is the default. Only opaque provider token references are stored, encrypted at rest, alongside non-sensitive card metadata (brand, last 4, expiry) for display + expiry handling — never card PAN/CVV. - Revolut cards are saved automatically the next time the user completes a Revolut checkout while auto-renewal is enabled (no separate setup step).
- New endpoints:
GET /api/v1/payment-methods— list saved methods (id,provider,name,card_brand,card_last_four,exp_month,exp_year,is_default,enabled,created). Tokens/NWC strings are never returned.POST /api/v1/payment-methods— add a Nostr Wallet Connect connection ({ nwc_connection_string, name? }); validated forpay_invoicesupport.PATCH /api/v1/payment-methods/{id}— set a user-definedname, set as default (is_default), and/or enable-disable (enabled).DELETE /api/v1/payment-methods/{id}— remove a saved method.
- Breaking: the
nwc_connection_stringfield onGET/PATCH /api/v1/accounthas been removed. Existing NWC connections are migrated intouser_payment_method(providernwc); manage NWC via the new payment-methods endpoints instead.
- Subscriptions with
- 2026-07-10 - Database-configured DNS providers + OVH reverse DNS (admin)
- DNS providers are now configured in the database (
dns_servertable) instead of the staticdnsblock inconfig.yaml. Each provider haskind("cloudflare"or"ovh"),url, and an encryptedtoken. GET /api/admin/v1/dns_servers— paginated list (id,name,enabled,kind,url,ip_range_count; token never returned). Requiresdns_server::view.GET /api/admin/v1/dns_servers/{id}— get one. Requiresdns_server::view.POST /api/admin/v1/dns_servers— create. Body:{ name, enabled?, kind, url?, token }. Cloudflare token is the bearer token; OVH token is"application_key:application_secret:consumer_key". Requiresdns_server::create.PATCH /api/admin/v1/dns_servers/{id}— update (all fields optional). Requiresdns_server::update.DELETE /api/admin/v1/dns_servers/{id}— delete (blocked while referenced by any IP range). Requiresdns_server::delete.- IP ranges gained
forward_dns_server_id,reverse_dns_server_id, andforward_zone_idfields (in addition to the existingreverse_zone_id) on the create/update/list admin endpoints, selecting which DNS provider + zone manages forward (A/AAAA) and reverse (PTR) records for the range. - New
ovhDNS provider implements reverse DNS (PTR) via OVH'sPOST/DELETE /ip/{ip}/reverse(reverse only; forward records must use another provider). Closes #78. - New
dns_serveradmin permission resource. POST /api/admin/v1/ip_ranges/{id}/patch_dns— queue aPatchIpRangeDnsjob that re-applies forward + reverse DNS for every IP assignment in a range, reconciling them to the range's current DNS server config (e.g. after switching reverse DNS to OVH). Returns aJobResponse. Requiresip_range::update.- Migration: the legacy
config.yamldnsblock is migrated into adns_serverrow on startup, and existing IP ranges are pointed at it automatically. OVH additional-IP routers are also imported asOvhDNS servers (reusing theirurl+ token), with reverse DNS auto-mapped onto the ranges they route. Existing OVH-routed IPs that still carry a stale Cloudflare reverse record are force-refreshed to a real OVH PTR (idempotent — keyed on the IP; working Cloudflare reverse records are left untouched). (The OVH consumer key may need/ip/*/reversepermissions granted for reverse DNS calls to succeed.) Record backfill/refresh is best-effort and never blocks startup.
- DNS providers are now configured in the database (
-
2026-06-25 - List configured notification channels
GET /api/v1/notification/channels— returns which notification channels are configured on this server so the UI can show/hide the relevant contact inputs. Response:{ "nip17": boolean, "email": boolean, "telegram": boolean, "whatsapp": boolean }. No authentication required.
-
2026-06-24 - Basic per-VM firewall rules (user API)
GET /api/v1/vm/{id}/firewall— list a VM's firewall rules (ordered bypriority).POST /api/v1/vm/{id}/firewall— create a rule. Body:{ priority?, direction: "inbound"|"outbound", protocol: "any"|"tcp"|"udp"|"icmp", action: "accept"|"drop"|"reject", src_cidr?, dst_port_start?, dst_port_end?, enabled? }. Returns the createdFirewallRule. Enforces a per-VM rule limit (configurable per template, default 20) and validatessrc_cidr(CIDR) and the port range (1–65535, start ≤ end).PATCH /api/v1/vm/{id}/firewall/{rule_id}— update a rule; all fields optional. Sendsrc_cidr: null/dst_port_*: nullto clear a field to "any".DELETE /api/v1/vm/{id}/firewall/{rule_id}— delete a rule.GET /api/v1/vm/{id}/firewall/policy— get the per-VM default firewall policy. Returns{ policy_in, policy_out }where each is"accept"|"drop"|"reject"ornull(inherit the host default, i.e. allow-all).PATCH /api/v1/vm/{id}/firewall/policy— set the per-VM default inbound/outbound policy. 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.- Any change queues an asynchronous re-apply (
ApplyVmFirewall) of the full ruleset on the host. Default policy remains allow-all inbound/outbound; host anti-spoof (IP filter) protection is always enforced regardless of user rules. Backend rule application on the host is implemented separately.
-
2026-06-23 - Enable/disable a tunnel on a router (admin)
POST /api/admin/v1/routers/{id}/tunnels/{name}/toggle— enable or disable a tunnel interface. Body:{ "enabled": boolean }. Returns aJobResponse({ "job_id": string }); applied asynchronously by the worker (Linuxip link set <iface> up|down, RouterOSdisabledflag), which then refreshes the tunnel cache. Requiresrouter::update.
-
2026-06-23 - Set/clear the static default route on a router (admin)
POST /api/admin/v1/routers/{id}/routes/default— install or replace the router's static default route. Body:{ "next_hop": string }(an IP address; the address family0.0.0.0/0vs::/0is inferred from it). Returns aJobResponse({ "job_id": string }); applied asynchronously by the worker, which refreshes the route cache afterwards. Requiresrouter::update.DELETE /api/admin/v1/routers/{id}/routes/default— remove the router's static default route(s) (idempotent). Returns aJobResponse. Requiresrouter::update.- Backed by new
BgpRouter::set_default_route/clear_default_routecapabilities (Linux/iproute2ip route replace|del default, RouterOS/ip|/ipv6/route). Only available on routers that support BGP/routing.
-
2026-06-23 - Router BGP route table visibility + IP range router attribution (admin)
GET /api/admin/v1/routers/{id}/bgp/routes— list the router's cached BGP route table: the prefixes it originates/announces plus a detected default route. EachAdminRouterBgpRoute:router_id,prefix(CIDR),next_hop,is_default,last_seen. Refreshed by the background sampler (~60s) alongside tunnels/BGP sessions, which replaces the whole per-router snapshot each cycle. Multiple routes to the same prefix (ECMP / differing next-hops) are preserved. Requiresrouter::view.- IP range responses (
GET /api/admin/v1/ip_ranges,GET /api/admin/v1/ip_ranges/{id}, and create/update) now include aroutersarray ({ id, name }) listing the routers that route the range, resolved via the range's access policy. Empty when the range has no access policy or the policy has no router.
-
2026-06-21 - Route server management: BGP session and tunnel visibility/control (admin)
- New
RouterKindvaluelinux_ssh— a Linux router managed over SSH (BIRD/Pathvector routing, iproute2/WireGuard tunnels). Configure withurl = ssh://<user>@<host>[:<port>]/<interface>andtoken= the SSH private key (PEM). Selectable viakindonPOST/PATCH /api/admin/v1/routers. GET /api/admin/v1/routers/{id}/tunnels— list cached tunnels discovered on the router. EachAdminRouterTunnel:id,router_id,name,kind(gre|vxlan|wireguard),local_addr,remote_addr,enabled,last_seen. Requiresrouter::view.GET /api/admin/v1/routers/{id}/tunnels/{name}/traffic— per-tunnel traffic history (AdminRouterTunnelTraffic:tunnel_name,rx_bytes,tx_bytes,sampled_at). Optionalfrom/toRFC3339 query params (default: last 24h). Tunnel interface counters are the canonical per-session traffic source — BGP sessions have no byte counters. Requiresrouter::view.GET /api/admin/v1/routers/{id}/bgp/sessions— list cached BGP sessions (AdminRouterBgpSession:id,router_id,name,peer_ip,peer_asn,local_asn,state,prefixes_received,prefixes_sent,enabled,direction(upstream|downstream|peer|unknown),last_seen). Requiresrouter::view.POST /api/admin/v1/routers/{id}/bgp/sessions/toggle— enable/disable a BGP session. Body:{ "session_id": string, "enabled": boolean }(session_idis the backend id from the sessions listing — protocol name on BIRD,.idon Mikrotik). Returns aJobResponse({ "job_id": string }); the action is applied asynchronously and the session cache refreshed. Requiresrouter::update.- Tunnel inventory, per-tunnel traffic, and BGP session state are refreshed by a background sampler (~60s). All router queries are bounded and safe on routers carrying a full DFZ table.
- New
-
2026-06-18 - Subscription line items now expose a typed
resourcereferenceSubscriptionLineItem(publicGET /api/v1/subscriptions/{id}) andAdminSubscriptionLineItemInfo(admin subscription + line-item endpoints) gain aresourcefield: a tagged union resolved server-side from the line item's subscription type. Shapes:{ "type": "vps", "vm_id": number },{ "type": "ip_range", "ip_range_subscription_id": number }, ornullwhen there is no linked resource.AdminSubscriptionLineItemInfonow also includes thesubscription_typediscriminant.
-
2026-06-18 -
GET /api/admin/v1/subscriptions— new optional filter query parameterssearch(string) — case-insensitive substring match against subscription name and descriptionstatus(active|inactive) — filter by theis_activeflag; omit for allauto_renewal(boolean) — filter by theauto_renewal_enabledflag; omit for all- All filters are optional and combine with AND (and with the existing
user_id). Filtering is applied before pagination, sototalreflects the filtered count. Response shape is otherwise unchanged.
-
2026-06-18 -
AdminSubscriptionInfonow includes auser_pubkeyfield- Hex-encoded Nostr pubkey of the owning user, returned alongside the existing
user_idby every endpoint that emits anAdminSubscriptionInfo:GET /api/admin/v1/subscriptions,GET /api/admin/v1/subscriptions/{id},POST /api/admin/v1/subscriptions,PATCH /api/admin/v1/subscriptions/{id}, and the embeddedsubscriptionobject onGET /api/admin/v1/vms/{id}.
- Hex-encoded Nostr pubkey of the owning user, returned alongside the existing
-
2026-06-15 -
GET /api/admin/v1/users/by-email— find a user by email address- Looks up a user via an indexed SHA-256 hash of the (lowercased, trimmed) email and returns the full
AdminUserInfo, or a"User not found"error if no match. - Query parameter:
email(required). Requires theusers::viewpermission. - Backed by a new
email_hashcolumn on the users table, backfilled for existing users at startup.
- Looks up a user via an indexed SHA-256 hash of the (lowercased, trimmed) email and returns the full
-
2026-04-03 - LNURL-pay endpoints for VM renewal restored
GET /.well-known/lnurlp/{id}— LNURL PayResponse for a VM. These endpoints were lost during the Rocket→Axum migration and are now working again (the path-parameter syntax was corrected for Axum).GET /api/v1/vm/{id}/renew-lnurlp?amount={millisats}— returns an invoice to extend the VM via LNURL pay.
-
2026-03-10 -
"creating"VM state for cleaner first-provision UX (closes #119)GET /api/v1/vm,GET /api/v1/vm/{id}—status.statenow transitions to"creating"immediately after the first payment is confirmed and before the VM is provisioned on the host. The state is replaced by a real host state ("running","stopped", etc.) once provisioning completes.GET /api/admin/v1/vms,GET /api/admin/v1/vms/{id}— Same"creating"state visible in the admin API.- This gives frontends a meaningful status to display instead of a stale
"stopped"state during initial provisioning.
-
2026-03-10 - WebSocket console endpoint for VM serial terminal access (User API)
ANY /api/v1/vm/{id}/console(WebSocket upgrade) — Bidirectional relay between the client and the VM's serial console via the host provisioner. Authentication is passed via query parameter?auth=<base64_nip98_event>.
-
2026-03-10 - Stripe payment completion handling implemented
POST /api/v1/webhook/stripe— Incoming Stripepayment_intent.succeededwebhooks are now verified and processed, marking the matching subscription payment paid and running the standard completion pipeline.- Note: Stripe payment creation (checkout/intent creation for
method=stripeon VM purchase, renewal, upgrade, and subscription renewal) is not yet implemented — those endpoints return an error formethod=stripe. Only completion of externally-created Stripe payments is wired up.
-
2026-03-10 -
LNURLadded as a payment method variantGET /api/v1/payment/methods— Response may now include{ "name": "lnurl", ... }when Lightning is enabled
-
2026-03-10 -
Upgradeadded as aSubscriptionPayment.payment_typevariantGET /api/v1/subscriptions/{id}/payments— Payments created for VM upgrades now carrypayment_type: "Upgrade"- Previously only
PurchaseandRenewalwere possible
-
2026-03-10 -
processing_feefield added toSubscriptionPaymentuser API responseGET /api/v1/subscriptions/{id}/payments— Each payment now includesprocessing_fee: { currency, amount }
-
2026-03-03 - Multi-interval VM renewal support
POST /api/v1/vm/{id}/renew— Accepts optionalintervalsquery parameter to pre-pay multiple billing periods at oncePOST /api/admin/v1/vms/{id}/renew— Sameintervalssupport in admin renewal endpoint
-
2026-02-25 - Resource limits on custom pricing plans, propagated to custom templates
POST /api/admin/v1/custom_pricing— Accepts new optional fields:disk_iops_read,disk_iops_write,disk_mbps_read,disk_mbps_write,network_mbps,cpu_limitPATCH /api/admin/v1/custom_pricing/{id}— Accepts the same limit fields; sendnullto remove a limitGET /api/admin/v1/custom_pricing/GET /api/admin/v1/custom_pricing/{id}— Response now includes limit fields (omitted when uncapped)- Limits are copied from the pricing plan into each
VmCustomTemplateat VM provisioning time (new VMs and template upgrades) None/ omitted = uncapped
-
2026-02-25 - Template resource limits for fair-use and SLA enforcement (closes #26)
POST /api/admin/v1/vm_templates— Accepts new optional fields:disk_iops_read,disk_iops_write,disk_mbps_read,disk_mbps_write,network_mbps,cpu_limitPATCH /api/admin/v1/vm_templates/{id}— Accepts the same limit fields; sendnullto remove a limitGET /api/admin/v1/vm_templates/GET /api/admin/v1/vm_templates/{id}— Response now includes limit fields (omitted when uncapped)- Limits are applied at VM create time and on any VM configure/upgrade:
- Disk IO:
mbps_rd/mbps_wr/iops_rd/iops_wron the primary disk via Proxmox API - Network bandwidth:
rate=Nonnet0interface - CPU limit:
cpulimitVM config option (fraction of allocated cores)
- Disk IO:
None/ omitted = uncapped (preserves existing behaviour for all current VMs)
-
2026-02-24 - Cloud image checksum verification and on-demand download (closes #69)
POST /api/admin/v1/vm_os_images/{id}/download— Enqueue an immediate download/re-check of an OS image on all hosts (requiresvm_os_image::update)PATCH /api/admin/v1/vm_os_images/{id}— Now correctly appliessha2andsha2_urlupdates- Worker:
DownloadOsImagesjob fetchessha2_url, compares checksum via SSH, and re-downloads stale images; checksum is also passed to Proxmoxdownload-urlAPI for in-flight verification
-
2026-02-24 - Added
company_base_currencyfield toAdminVmPaymentInfoGET /api/admin/v1/vms/{id}/payments— Response now includescompany_base_currencyGET /api/admin/v1/vms/{id}/payments/{payment_id}— Response now includescompany_base_currencyPOST /api/admin/v1/vms/{id}/payments/{payment_id}/complete— Response now includescompany_base_currency
-
2026-02-23 - Sponsoring LIR Agreement generation (User API)
GET /api/v1/legal/sponsoring-lir-agreement?data={base64url_json}— Renders an unsigned LIR agreement HTML document from base64url-encoded JSON agreement data. Rejects data that carries a cryptographic proof.GET /api/v1/legal/sponsoring-lir-agreement/from-subscription/{subscription_id}(NIP-98 auth) — Generates a cryptographically signed LIR agreement for one of the caller's own subscriptions, populating provider/end-user details from company and billing data. Returns aSignedAgreementUrlResponse.
-
2026-02-23 - Admin endpoints to manually complete payments
POST /api/admin/v1/vms/{id}/payments/{payment_id}/complete— Mark a VM payment as paid, extend VM expiry, and dispatch provisioning (requirespayments::update)POST /api/admin/v1/subscription_payments/{id}/complete— Mark a subscription payment as paid, extend subscription by 30 days, and activate it (requiressubscription_payments::update)
- 2026-07-06 -
PATCH /api/v1/vm/{id}/re-installon an expired VM now returns402 Payment Requiredwith a clear message instead of500 Internal Server Error. The expiry is checked up-front (before touching the host) so an expired VM can no longer trigger a failed reinstall pipeline. (#141)
-
2026-07-06 - More accurate HTTP status codes on error responses (full audit of the user and admin APIs). API errors previously almost always returned
500 Internal Server Error; they now use appropriate codes:400 Bad Request— client/validation errors (e.g. invalid SSH key, invalid lightning address, empty/invalid fields on admin create/update, invalid date ranges on reports). Many admin validation errors previously returned500.401 Unauthorized— authentication failures.403 Forbidden— accessing a resource you don't own (VM/subscription/SSH key), ordering before email verification, modifying system roles, and insufficient admin permissions (previously500).404 Not Found— missing resources, including any database lookup that finds no matching row, and nested resources not under their parent (e.g. a firewall rule / payment / history entry that doesn't belong to the given VM).409 Conflict— state conflicts (e.g. already enrolled in referrals, acting on an already-deleted VM, a payment that is already completed, assigning an IP to a deleted/expired VM).501 Not Implemented— not-yet-implemented subscription types (ASN sponsoring, DNS hosting).- Genuine internal failures continue to return
500. Response bodies are unchanged ({ "error": string }).
-
2026-06-25 - Subscription payments now include the payment data needed to pay
ApiSubscriptionPayment(returned byGET /api/v1/subscriptions/{id}/renew,GET /api/v1/subscriptions/{id}/payments, and the admin subscription-payment endpoints) gains adatafield carrying the payment-method-specific data, e.g.{ "lightning": "lnbc..." }for Lightning. Previously the renew endpoint returned a payment record with no way to actually pay it.
-
2026-06-25 - Email verification is only required to order a VM when SMTP is configured
POST /api/v1/vmandPOST /api/v1/vm/custom-templatepreviously always rejected orders from users without a verified email. The check is now skipped when the server has nosmtpconfig (verification emails can't be sent), so ordering remains usable on installs without email. The API logs a startup warning when SMTP is unconfigured.
-
2026-06-18 -
subscription_typeis now immutable on subscription line itemsPATCH /api/admin/v1/subscription_line_items/{id}no longer acceptssubscription_type. A line item is bound to its resource at creation time, so its type must not change afterward (previously the field was accepted but silently ignored).
-
2026-06-18 -
configurationon subscription line items is now upgrade bookkeeping only- Previously documented as a tagged resource link (
{ "type": "vps", "vm_id": ... }). It is now returned as raw JSON holding upgrade data (e.g.new_cpu/new_memory/new_disk) and isnullfor line items that have never been upgraded. Resolve the linked resource via the newresourcefield instead. - SubscriptionType values are now serialized in
snake_case("vps","ip_range","asn_sponsoring","dns_hosting") wherever the enum appears in JSON (e.g. the adminsubscription_typefield and the create-line-item request body), matching the rest of the API.
- Previously documented as a tagged resource link (
-
2026-04-03 - Unpaid VMs with a non-expired pending payment are no longer auto-deleted
- The worker cleanup loop now skips deletion of unpaid VMs that still have a pending (non-expired) payment, giving slower payment methods (e.g. Revolut) time to settle before the VM is removed.
-
2026-03-10 -
VmRunningStatesenum simplified —"starting"and"deleting"removedGET /api/v1/vm,GET /api/v1/vm/{id}—status.statenow has four possible values:"unknown"(default before first poll),"running","stopped","creating". The former"starting"and"deleting"variants are no longer emitted.GET /api/admin/v1/vms,GET /api/admin/v1/vms/{id}— Same change applies torunning_state.state."unknown"is now the default value when no state has been cached yet, replacing the previous implicit"stopped"default.
-
2026-03-10 -
VmStatus.expiresis now nullableGET /api/v1/vm,GET /api/v1/vm/{id}— Theexpiresfield is nowstring | null(was always a string). It will benullfor newly created VMs that have not yet been paid.
-
2026-03-10 -
GET /api/v1/vm/{id}/paymentsnow uses database-level pagination- The endpoint now accepts
?limit=N&offset=Nquery parameters and returns a paginated response (data,total,limit,offset). Previously the list was unbounded.
- The endpoint now accepts
-
2026-03-03 - Admin subscription list now returns results in descending order
GET /api/admin/v1/subscriptions— Results ordered byid DESC(newest first); applies to both the all-subscriptions list and the?user_id=Nfiltered list
-
2026-03-03 - Admin VM info response now includes subscription details
GET /api/admin/v1/vms/{id}— Response now includes asubscriptionobject with the fullAdminSubscriptionInfo(id, status, interval, currency, line items, payment count); omitted if no subscription is linked
-
2026-03-03 - Admin subscription payment response now includes
company_base_currencyGET /api/admin/v1/subscriptions/{id}/payments— Each payment now includescompany_base_currencyGET /api/admin/v1/subscription_payments/{id}— Response now includescompany_base_currencyPOST /api/admin/v1/subscription_payments/{id}/complete— Response now includescompany_base_currency
-
2026-03-03 - VM payments now use the unified
subscription_paymenttable- All VM renewal, purchase, and upgrade payments are now stored in
subscription_paymentinstead ofvm_payment GET /api/v1/vm/{id}/payments— Response format unchanged; now backed bysubscription_payment; supports pagination via?limit=N&offset=Nquery paramsGET /api/v1/vm/{id}/payments/{payment_id}— Now looks up bysubscription_payment.idGET /api/v1/vm/{id}/payments/{payment_id}/invoice— Now backed bysubscription_paymentPOST /api/v1/vm/{id}/renew— Returns payment fromsubscription_paymentPOST /api/v1/vm/{id}/upgrade— Returns payment fromsubscription_payment; upgrade parameters stored inmetadataJSON fieldGET /api/admin/v1/vms/{id}/payments— Now backed bysubscription_payment; uses real DB-level paginationGET /api/admin/v1/vms/{id}/payments/{payment_id}— Now looks up bysubscription_payment.idPOST /api/admin/v1/vms/{id}/payments/{payment_id}/complete— Now completes asubscription_paymentGET /api/admin/v1/reports/time-series— Revenue data now sourced fromsubscription_paymentGET /api/admin/v1/reports/referral-usage/time-series— Referral data now sourced fromsubscription_payment- Automatic data migration: existing VMs and
vm_paymentrows are backfilled into the subscription system automatically at app startup (no manual step). The backfill runs after schema migrations and before any VM reads, and is idempotent. - Schema migrations:
20260302151134_vm_subscription_link.sql(the DB-levelNOT NULLonvm.subscription_line_item_idand the drop of legacyvm.expires/created/auto_renewal_enabledare deferred to finalization, run manually after production verification)
- All VM renewal, purchase, and upgrade payments are now stored in
-
2026-03-03 - Every VM is now linked to a
subscriptionandsubscription_line_itemvmtable has a newsubscription_line_item_idcolumn (NOT NULL) linking it to the subscriptions system- New VMs provisioned via
POST /api/v1/vmorPOST /api/v1/vm/customautomatically get a subscription created - The subscription interval is copied from the cost plan (standard VMs) or defaults to 1 month (custom VMs)
-
2026-03-03 -
IntervalTypeenum renamed fromVmCostPlanIntervalType- Affects admin responses that include cost plan or subscription interval information
-
2026-02-25 - Email verification is now required before creating a VM (closes #92)
POST /api/v1/vm— Returns400with error message if the user's email is not verifiedPOST /api/v1/vm/custom-template— Same gate applied
- 2026-03-10 - Clarification:
POST /api/admin/v1/vms/{id}/renewdoes not exist- The 2026-03-03 changelog entry incorrectly stated that multi-interval renewal was added to an admin renew endpoint. No such endpoint exists in the admin API. Multi-interval renewal is only available via the user-facing
GET /api/v1/vm/{id}/renew?intervals=N.
- The 2026-03-03 changelog entry incorrectly stated that multi-interval renewal was added to an admin renew endpoint. No such endpoint exists in the admin API. Multi-interval renewal is only available via the user-facing
-
2026-07-07 - Codebase audit: correctness and safety fixes
- Payments: subscription payment settlement is now idempotent — duplicate webhook deliveries or replayed Lightning settle events no longer extend a subscription's expiry more than once (
subscription_payment_paidnow guards onis_paidand skips the extension when the payment was already paid). Revolut order metadata is now persisted on settlement. - Custom VM orders: requested CPU/memory/disk are now validated against the pricing plan's configured min/max limits, disk pricing is matched on both disk kind and interface, and sub-GB memory/disk is billed (rounded up) instead of truncating to zero. Previously a custom order could be under-billed or effectively free.
- VM lifecycle:
PATCH /api/v1/vm/{id}/restartnow actually restarts the VM (it previously only issued a stop, leaving the VM powered off). VM deletion no longer frees the DB record and IP assignments when the hypervisor is merely unreachable — only a definitive 404 is treated as "already gone"; transient errors abort the delete. Proxmox HTTP errors are now classified fatal/transient by status code instead of blanket-retrying 4xx. - Renewals & pricing: a pending renewal invoice is no longer reused for a request covering a different number of intervals (which let a multi-interval request be paid off with a smaller single-interval invoice); VPS-only subscription setup fees are now charged on the first invoice; and extending an already-expired VM now clamps the new expiry to "now" consistently with every other renewal path.
- Robustness: fixed remote-triggerable panics in NIP-98 auth (malformed single-element tags) and VAT-number parsing (multi-byte input), and a panic in VM status when an IP range fails to load.
- Admin/DB: creating or updating available IP space now persists
company_id(the insert previously failed on the NOT-NULL column); non-VM subscription payments are correctly attributed to their company in admin payment views and revenue reports;admin_update_companynow persistsbase_currency; and per-region capacity stats no longer under-count hosts that share the same CPU/memory values. - Support agent, health checks & host tooling: the support agent no longer crashes on non-ASCII messages or empty LLM responses, uses UID-based IMAP operations (no duplicate replies), and URL-encodes email lookups; health-check metrics handle IPv6 DNS servers and IPv6 bind addresses; and host CPU-feature detection uses a safe CPUID intrinsic with a max-leaf guard.
- XDP firewall: corrected the SYN rate-limiter token-bucket refill math (previously refilled far too fast, defeating the limit), stopped counting SYN-ACK replies, and guarded against a zero-limit division.
- Payments: subscription payment settlement is now idempotent — duplicate webhook deliveries or replayed Lightning settle events no longer extend a subscription's expiry more than once (
-
2026-06-25 - Admin VM
template_idis nownullfor custom-template VMsAdminVmInfo.template_id(returned byGET /api/admin/v1/vmsandGET /api/admin/v1/vms/{id}) changed fromu64to a nullable integer. VMs on a custom template previously reportedtemplate_id: 0; they now correctly reporttemplate_id: null(with the linked template carried bycustom_template_id). Standard-template VMs are unaffected.
-
2026-06-25 - Expired subscriptions are now handled even when expiry predates the last check
- The worker's
CheckSubscriptionsexpiry handling previously only fired when a subscription crossed its expiry between two check cycles (expires >= last_check). Subscriptions that expired before the last check (admin/retroactive expiry, clock changes, or worker downtime) were left running until the grace period elapsed. The worker now fires the one-shot expiry handling (stop VM + notify) for any expired-but-in-grace subscription, using VM history as an idempotency marker so it still acts exactly once.
- The worker's
-
2026-06-23 - Toggling a BGP session now persists on routers where the backend session id differs from the session name (e.g. Mikrotik, where the id is
.idand the name is the protocol name). PreviouslyPOST /api/admin/v1/routers/{id}/bgp/sessions/toggleupdated nothing in the cache for such routers because the persist was keyed by the backend id instead of the cached session name. -
2026-06-16 - VM→subscription backfill now runs reliably at startup
- The backfill is executed during app startup, after schema migrations and before any VM read, and preserves each VM's existing expiry and auto-renewal preference. The legacy
vm.expires/vm.createdcolumns are no longer dropped before the backfill runs, which previously caused the backfill to fail for every VM and break all VM reads. (No external API surface change — listed for operator awareness.)
- The backfill is executed during app startup, after schema migrations and before any VM read, and preserves each VM's existing expiry and auto-renewal preference. The legacy
-
2026-04-26 - Region capacity no longer reports IP ranges as full incorrectly
GET /api/v1/vm/templatesand region availability — the gateway IP is now only counted as a used address when it actually falls within the allocation CIDR. Previously a gateway outside the range inflated the used-IP count and could falsely report a region/range as full while free IPs remained.
-
2026-04-02 - Payments for already-deleted VMs are handled gracefully
POST /api/v1/vm/{id}/renewand payment confirmation — a payment that arrives for a VM auto-deleted before the (slow) payment settled now un-deletes the VM and applies the payment instead of erroring; the VM is then re-provisioned by the next check. Renewal/invoice creation for VMs that remain deleted is rejected with"VM not found".- A race where a VM paid between the cleanup snapshot and the deletion step could be deleted is fixed by re-reading VM state immediately before deletion.
-
2026-03-10 - VM subscription lookup query used incorrect type filter
- Internal fix: the query that finds a VM's linked subscription was incorrectly using
IN (3, 4)instead of= 3, which could return incorrect results.
- Internal fix: the query that finds a VM's linked subscription was incorrectly using
-
2026-03-10 -
ApiVmPayment::from_subscription_paymentnow propagates JSON parse errors- Previously, a malformed
metadataJSON field in asubscription_paymentrow would be silently ignored, potentially returning incorrect upgrade parameter data. Errors are now surfaced to the API caller.
- Previously, a malformed
-
2026-03-10 - Expiry notification always sent when NWC auto-renewal is inactive
- Workers now always send the expiry notification email/NIP-17 DM even when NWC is configured but
auto_renewal_enabledis false for the subscription.
- Workers now always send the expiry notification email/NIP-17 DM even when NWC is configured but
-
2026-03-03 - VM upgrade no longer leaves subscription renewal cost stale
POST /api/v1/vm/{id}/upgrade— After payment confirmation,SubscriptionLineItem.amountis now updated to the new base-currency cost of the upgraded template for both standard→custom and custom→custom upgrade pathsGET /api/v1/subscriptions/{id}and admin equivalents —line_items[].pricenow reflects the post-upgrade renewal cost immediately after an upgrade completes
-
2026-03-03 - Migration tool no longer marks subscriptions active for deleted VMs
- VM subscription backfill — Subscriptions created for deleted VMs are now inserted with
is_active = false
- VM subscription backfill — Subscriptions created for deleted VMs are now inserted with
-
2026-02-23 - Fixed inability to unset
cpu_mfg,cpu_arch,cpu_featuresfields via PATCH endpointsPATCH /api/admin/v1/vm_templates/{id}— Now supports settingcpu_mfg,cpu_arch,cpu_featurestonullto clear valuesPATCH /api/admin/v1/custom_pricing/{id}— Now supports settingcpu_mfg,cpu_arch,cpu_featurestonullto clear valuesPATCH /api/admin/v1/hosts/{id}— Now supports settingcpu_mfg,cpu_arch,cpu_featurestonullto clear values- Previously, sending
nullfor these fields was treated the same as omitting them (no change)
-
2026-07-06 - Documented previously-undocumented user API endpoints in
API_DOCUMENTATION.md- Added docs for the Telegram/WhatsApp notification-linking endpoints:
POST/DELETE /api/v1/account/telegram/link,POST/DELETE /api/v1/account/whatsapp/verify, andPOST /api/v1/account/whatsapp/confirm. - Added a new "Nostr Domains (NIP-05)" section documenting
GET/POST /api/v1/nostr/domain,GET/POST /api/v1/nostr/domain/{dom}/handle, andDELETE /api/v1/nostr/domain/{dom}/handle/{handle}, including theNostrDomainandNostrDomainHandledata types. No API behaviour changed.
- Added docs for the Telegram/WhatsApp notification-linking endpoints:
-
2026-06-23 - Documented the BGP session and tunnel field semantics in
ADMIN_API_ENDPOINTS.mdand rustdoc. Clarified thatenabled(administrative on/off) andstate(live BGP FSM state:Idle/Connect/Active/OpenSent/OpenConfirm/Established/Down) are independent —"enabled": truewith"state": "Down"is administratively on but not yet up, not a contradiction. Also documented tunnel"any"endpoints and thedirectionclassification.
- 2026-02-22 - Reduced unpaid VM deletion time from 24 hours to 1 hour
- Unpaid VM orders are now deleted after 1 hour instead of 24 hours
- Fixes #63
-
2026-02-22 - Added
disabledfield to VM model and admin PATCH endpointvmtable now includesdisabledcolumn (default: false)AdminVmInforesponse now includesdisabledfield in all GET endpointsPATCH /api/admin/v1/vms/{id}— New endpoint to update VM properties- Allows admins to disable/enable VMs without deleting them
- When disabled state changes, a
ConfigureVmwork job is dispatched to reconfigure the VM on the host - On Proxmox hosts, disabled VMs have
link_down=1set on their network interface
-
2026-02-22 - Added
mtufield to host configurationvm_hosttable now includesmtucolumn (optional, SMALLINT UNSIGNED)AdminHostInforesponse now includesmtufield in all GET endpointsPOST /api/admin/v1/hosts— Added optionalmtufield for host creationPATCH /api/admin/v1/hosts/{id}— Added optionalmtufield for host update (usenullto clear)
-
2026-02-22 - Added additional fields to sales time-series report
GET /api/admin/v1/reports/time-series— Response now includesuser_id,host_id,host_name,region_id,region_namefields in each payment record- Enables client-side filtering by user, host, or region
-
2026-02-21 - Added endpoint to list free IPs in an IPv4 range (Admin API)
GET /api/admin/v1/ip_ranges/{id}/free_ips— Returns list of unassigned IP addresses- Only available for IPv4 ranges; IPv6 ranges return an error (too large to enumerate)
- Excludes reserved IPs (gateway, network address, broadcast address)
-
2026-02-20 - Added
supported_currenciesto payment method configurationAdminPaymentMethodConfigInfo— Newsupported_currenciesfield (array of currency codes)CreatePaymentMethodConfigRequest— New optionalsupported_currenciesfieldUpdatePaymentMethodConfigRequest— New optionalsupported_currenciesfieldGET /api/v1/payment/methods— Now returns currencies from DB config instead of hardcoded defaults- Empty array means use default currencies based on payment method type (Lightning: BTC, others: EUR/USD)
-
2026-02-20 - Added
cpu_mfg,cpu_arch,cpu_featuresto all admin API response modelsAdminVmInfo— Now includes CPU specification fields from the VM's templateAdminVmTemplateInfo— Now includes CPU specification fieldsAdminCustomPricingInfo— Now includes CPU specification fieldsAdminHostInfo— CPU fields are now consistently documented as optional (omitted when unknown/empty)POST /api/admin/v1/hosts— Added optionalcpu_mfg,cpu_arch,cpu_featuresfields for host creationPATCH /api/admin/v1/hosts/{id}— Added optionalcpu_mfg,cpu_arch,cpu_featuresfields for host update- Fields are omitted from JSON when value is unknown (cpu_mfg/cpu_arch) or empty (cpu_features)
-
2026-02-20 - Added
paid_attimestamp to payment responsesVmPayment— New optionalpaid_atfield (ISO 8601 datetime) indicating when the payment was completedSubscriptionPayment— New optionalpaid_atfield (ISO 8601 datetime) indicating when the payment was completedAdminVmPaymentInfo— New optionalpaid_atfield for admin payment viewsAdminSubscriptionPaymentInfo— New optionalpaid_atfield for admin payment views- Field is only present when
is_paidis true; null/omitted for unpaid payments
-
2026-02-20 - Added processing fee information to payment methods response
GET /api/v1/payment/methods— Response now includes optionalprocessing_fee_rate,processing_fee_base, andprocessing_fee_currencyfieldsprocessing_fee_rate: Percentage rate (e.g., 1.0 for 1%)processing_fee_base: Base amount in smallest currency units (cents for fiat, millisats for BTC)processing_fee_currency: Currency for the base fee (e.g., "EUR")- NWC payment method is now only returned when Lightning is enabled
-
2026-02-20 - Added CPU-aware host filtering to VM Templates, Custom Pricing, and Hosts (Admin API)
- New enums:
CpuMfg,CpuArch,CpuFeature,GpuMfg POST /api/admin/v1/vm_templates— Added optionalcpu_mfg,cpu_arch,cpu_featuresfieldsPATCH /api/admin/v1/vm_templates/{id}— Added optionalcpu_mfg,cpu_arch,cpu_featuresfieldsPOST /api/admin/v1/custom_pricing— Added optionalcpu_mfg,cpu_arch,cpu_featuresfieldsPATCH /api/admin/v1/custom_pricing/{id}— Added optionalcpu_mfg,cpu_arch,cpu_featuresfieldsAdminHostInforesponse now includescpu_mfg,cpu_arch,cpu_features(detected via lnvps-host-info)- When
cpu_mfg/cpu_archis "unknown" orcpu_featuresis empty, no filtering is applied (matches any host)
- New enums:
-
2026-02-20 - Added SSH credentials for host utilities to Admin Host API
POST /api/admin/v1/hosts— Added optionalssh_userandssh_keyfields for host creationPATCH /api/admin/v1/hosts/{id}— Added optionalssh_userandssh_keyfields for host updateAdminHostInforesponse now includesssh_user(string or null) andssh_key_configured(boolean)- SSH key itself is never exposed in responses for security (only a boolean indicator)
- SSH credentials are used by the PatchHosts worker to run
lnvps-host-infoutility for CPU/GPU detection
-
2026-02-20 - Added CPU feature requirements to custom VM requests (User API)
POST /api/v1/vm/custom—cpu_mfg,cpu_arch,cpu_featurefields now accept strings instead of enums- Valid
cpu_mfgvalues: "intel", "amd", "apple", "nvidia", "unknown" - Valid
cpu_archvalues: "x86_64", "arm64", "unknown" - CPU features are parsed from strings (e.g. "AVX2", "AES", "VMX"); invalid values are silently ignored
-
2026-02-19 - Added Referral Program API endpoints
POST /api/v1/referral- Enroll in referral program with lightning address or NWC payout optionsGET /api/v1/referral- Get referral state including per-currency earnings, payout history, and success/failed countsPATCH /api/v1/referral- Update payout options (lightning_address, use_nwc)
-
2026-02-17 - Added embedded API documentation served at root path (both User and Admin APIs)
GET /orGET /index.html- Renders API documentation with markdown viewerGET /docs/endpoints.md- Raw markdown content of API endpoints documentationGET /docs/changelog.md- Raw markdown content of API changelog- Documentation is embedded at compile time using
include_str!and rendered client-side with marked.js - User API serves
API_DOCUMENTATION.md, Admin API servesADMIN_API_ENDPOINTS.md
-
2026-02-17 - Added
taxandprocessing_feefields toAdminVmPaymentInforesponse- Affected endpoints:
GET /api/admin/v1/vms/{vm_id}/payments,GET /api/admin/v1/vms/{vm_id}/payments/{payment_id} - Both fields are
u64in smallest currency unit (cents for fiat, millisats for BTC)
- Affected endpoints:
-
2026-02-17 - Added
processing_feefield toAdminSubscriptionPaymentInforesponse- Affected endpoints:
GET /api/admin/v1/subscriptions/{id}/payments,GET /api/admin/v1/subscriptions/{id}/payments/{payment_id} - Field is
u64in smallest currency unit (cents for fiat, millisats for BTC)
- Affected endpoints:
-
2026-02-18 - BREAKING CHANGE:
ApiPrice.amountchanged fromf32tou64in smallest currency units- The
Pricetype returned by user-facing endpoints now usesu64integers instead of floats - Amounts are in smallest currency units: cents for fiat (EUR, USD, etc.), millisats for BTC
- Affected endpoints: all endpoints returning
Priceobjects, including:GET /api/v1/templates—VmCostPlan.amountGET /api/v1/vm/{id}/upgrade/quote—cost_difference,new_renewal_cost,discountGET /api/v1/subscriptions/GET /api/v1/subscriptions/{id}—SubscriptionLineItem.price,SubscriptionLineItem.setup_feeGET /api/v1/subscriptions/{id}/payments—SubscriptionPayment.amount,SubscriptionPayment.taxPOST /api/v1/vm/custom/price— returnedPrice
- Example:
"amount": 10.99(EUR float) becomes"amount": 1099(cents) - Example:
"amount": 0.00012345(BTC float) becomes"amount": 12345(millisats)
- The
-
2026-02-16 - BREAKING CHANGE: All money amounts now use
u64in smallest currency units (cents for fiat, millisats for BTC)- Requires database migration: Run
20260217100000_amount_to_cents.sqlwhich converts existing data - Cost plan
amountfield changed fromf32(human-readable) tou64(smallest units) - Custom pricing costs (
cpu_cost,memory_cost,ip4_cost,ip6_cost) changed fromf32tou64 - Custom pricing disk
costfield changed fromf32tou64 - VM template
cost_plan_amountfield changed fromf32tou64 - Payment method config
processing_fee_basefield changed fromf32tou64 - Affected endpoints:
POST /api/admin/v1/cost_plans,PATCH /api/admin/v1/cost_plans/{id}POST /api/admin/v1/custom_pricing,PATCH /api/admin/v1/custom_pricing/{id}POST /api/admin/v1/vm_templates,PATCH /api/admin/v1/vm_templates/{id}POST /api/admin/v1/custom_pricing/{id}/calculatePOST /api/admin/v1/payment_methods,PATCH /api/admin/v1/payment_methods/{id}
- Example:
"amount": 10.99(EUR) becomes"amount": 1099(cents) - Example:
"cpu_cost": 0.05(BTC) becomes"cpu_cost": 5000000(millisats = 5000 sats) - Example:
"processing_fee_base": 0.20(EUR) becomes"processing_fee_base": 20(cents)
- Requires database migration: Run
-
2026-02-16 - Payment method config updates now support partial config updates
PATCH /api/admin/v1/payment_methods/{id}now acceptsPartialProviderConfiginstead of fullProviderConfig- Only fields included in the request are updated; missing fields retain their existing values
- The
typefield is still required to identify the provider type - Cannot change provider type during update (e.g., from
lndtorevolut)
- 2026-02-16 - Bitvora payment provider has been disabled
- Bitvora service has been shut down and is no longer available
- The
bitvoraprovider type is no longer supported for new configurations - Existing Bitvora configurations in the database are preserved for historical reference
- Affected endpoints:
POST /api/admin/v1/payment_methods,PATCH /api/admin/v1/payment_methods/{id}
- 2026-02-16 - Sanitized sensitive fields in
AdminPaymentMethodConfigInforesponses- Provider config secrets (tokens, API keys, webhook secrets) are no longer returned in GET/list responses
- Affected endpoints:
GET /api/v1/admin/payment-config,GET /api/v1/admin/payment-config/{id} - Secrets are replaced with boolean indicators (e.g.,
has_token: true,has_webhook_secret: true) - Public/non-sensitive fields (URLs, client IDs, publishable keys) are still returned