Back to Docs
Internal

White-Label Admin Guide

Definitive internal reference for the Packet.ai team on managing white-label tenants, onboarding partners, configuring domains and SSL, and troubleshooting.

System Architecture

The white-label system allows Packet.ai to serve the full Next.js application under a partner's brand. Each tenant gets their own domain, branding, Stripe integration, GPU pricing, and customer base. The same codebase serves every tenant -- the active tenant is resolved at runtime from the request hostname.

What Tenants Get

  • Complete customer dashboard with their branding, colors, and logo
  • Full documentation site, status page, and API docs
  • Embeddable widget system (pricing, checkout, auth, gpu-status) rendered in Shadow DOM
  • WHMCS module for provisioning lifecycle management
  • REST API with X-API-Key authentication
  • Preview URLs at {slug}.tenants.packet.ai for instant testing
  • Lead tracking with drip emails and webhook notifications

Database Models

Four Prisma models power the tenant system. All are defined in prisma/schema.prisma.

ModelPurposeKey Fields
TenantMaster record for each tenantbrandName, slug, status, colors, Stripe keys, support config, GPU config
TenantDomainDomains per tenantdomain, isPrimary, verifiedAt
TenantGpuPricingPer-GPU pricing configurationgpuType, hourlyRateCents, monthlyRateCents, wholesaleCostCents
TenantCustomerCustomers belonging to a tenantemail, stripeCustomerId, name

Tenant Resolution

Tenant resolution happens on every request. The middleware (src/middleware.ts) sets an x-tenant-host header from the incoming request hostname. The resolver (src/lib/tenant/resolve.ts) then maps that hostname to a tenant config.

Resolution Flow

  1. Default hostnames -- localhost, dash.packet.ai, packet.ai, and tenants.packet.ai all resolve to the default Packet.ai config. No database lookup.
  2. Preview subdomains -- Hostnames matching {slug}.tenants.packet.ai trigger a lookup by slug. No domain verification is needed for preview URLs.
  3. Custom domains -- All other hostnames trigger a TenantDomain lookup. The domain record must have verifiedAt set (not null) to resolve. Unverified domains fall back to the default Packet.ai config.

Caching

Resolved tenant configs are cached in-memory for 5 minutes per hostname. This means changes to tenant settings (branding, pricing, etc.) can take up to 5 minutes to propagate. There is currently no cache-busting mechanism -- if you need immediate propagation, restart the process.

// Resolution priority (src/lib/tenant/resolve.ts): // // 1. Check if hostname is a default → return default config // 2. Check in-memory cache → return cached if < 5 min old // 3. Check if hostname is {slug}.tenants.packet.ai → DB lookup by slug // 4. Otherwise → DB lookup TenantDomain where domain = hostname AND verifiedAt != null // 5. No match → return default config

Branding System

Tenant branding is injected at the layout level using two components.

TenantStyles (src/components/TenantStyles.tsx)

Injects CSS custom properties into :root based on the resolved tenant config. This overrides the default Packet.ai color variables so the entire UI adapts automatically. Also injects any customCss the tenant has configured.

TenantBrand (src/components/TenantBrand.tsx)

Exports TenantLogo() and TenantName() components used in the dashboard header, login pages, and other locations where the brand identity appears.

Branding Fields

FieldPurposeNotes
brandNameDisplay nameShown in dashboard header, emails, page titles
logoUrlLogo imageDirect URL to SVG or PNG. Used in header and widgets.
faviconUrlFaviconOptional. Falls back to Packet.ai default.
ogImageUrlOpen Graph imageUsed for social media previews
primaryColorPrimary brand colorButtons, links, active states. Hex format.
accentColorAccent colorBadges, highlights, secondary actions
bgColorBackground colorPage background
textColorText colorBody text
customCssRaw CSSInjected after all other styles. Use sparingly.

Creating a New Tenant

Navigate to the admin panel at /admin?tab=tenants and click “New Tenant”. The 6-step onboarding wizard (src/app/admin/components/TenantOnboardingWizard.tsx) guides you through the setup.

Admin access required: All admin endpoints require a valid admin_session cookie (JWT + 2FA TOTP). You must be logged in at /admin with two-factor authentication.

Step 1: Brand Information

  • Brand Name -- The display name shown to customers (e.g., “Acme GPU Cloud”)
  • Slug -- Auto-generated URL-safe identifier. Must be unique across all tenants. Used for preview URLs and internal references.
  • Logo URL -- Direct link to SVG or PNG. Displayed in the dashboard header, widgets, and emails.
  • Favicon URL -- Optional. Falls back to Packet.ai default if not set.
  • OG Image URL -- Optional. Used for social media link previews.
  • Colors -- Primary color (buttons, links), accent color (badges, highlights), background color, text color. All hex format.
  • Custom CSS -- Optional raw CSS injected after all other styles. For advanced customization.

Step 2: Domain Configuration

  • Primary Domain -- The main domain customers will use (e.g., gpu.acme.com). Must be unique across all tenants.
  • Additional Domains -- Optional aliases. Each domain gets its own TenantDomain record.
  • All custom domains require a CNAME record pointing to tenants.packet.ai.
  • Domains are created with verifiedAt = null and are verified separately (see Domain Verification section).
Tip: The tenant is immediately accessible via its preview URL at {slug}.tenants.packet.ai -- no DNS setup needed. Use this to show the provider their branded site before they configure DNS.

Step 3: Stripe Configuration

  • Publishable Key -- Starts with pk_test_ or pk_live_. Stored in plaintext (public by design).
  • Secret Key -- Starts with sk_test_ or sk_live_. Encrypted at rest with AES-256-GCM (src/lib/crypto).
  • Webhook Secret -- Optional but recommended for production. Starts with whsec_. Also encrypted at rest.
  • Sandbox Mode -- Toggle to skip Stripe keys entirely. Creates tenant with empty Stripe config. The API route accepts sandboxMode: true and skips Stripe key validation.

Step 4: GPU Selection & Pricing

  • Select which GPU types to offer. Each GPU shows the wholesale cost (our cost basis).
  • Set the selling price in cents/hr (hourlyRateCents). The margin is calculated and displayed automatically.
  • Optional monthly rate (monthlyRateCents) for subscription pricing.
  • Default selling price is 1.5x wholesale (50% markup). Adjust per GPU as needed.
  • Each entry creates a TenantGpuPricing record.

Step 5: Support & Settings

  • Support Email -- Displayed to tenant customers for help requests. Also used as the sender for drip emails.
  • Status Page -- Toggle on/off. Shows GPU availability and incident history when enabled.
  • Lead Webhook URL -- Optional. Receives lead events (signup, first deploy, payment). See Webhook Events section.
  • Alert Webhook URL -- Optional. Receives alert events (GPU down, high usage).

Step 6: Review & Create

Review all settings and click “Create Tenant.” The system performs the following in a single transaction:

  1. Creates the Tenant record with all brand, Stripe, and support config
  2. Creates TenantDomain records for each configured domain
  3. Creates TenantGpuPricing records for each selected GPU
  4. Generates an API key (pak_ + 32 hex chars via crypto.randomBytes(16))

The success screen shows the widget embed code, DNS instructionsfor each domain, and the generated API key (shown only once -- save it).

Managing Tenants

Admin Panel

The tenant management UI is at /admin?tab=tenants. It is accessible only to Packet.ai admins with a valid JWT session and 2FA TOTP verification. Key components:

  • src/app/admin/components/TenantsTab.tsx -- main tenant list and detail view
  • src/app/admin/components/TenantOnboardingWizard.tsx -- 6-step creation wizard
  • src/app/admin/components/TenantMarginDashboard.tsx -- revenue and margin analytics
  • src/app/admin/components/TenantDripConfig.tsx -- drip email configuration

Tenant Statuses

StatusBehaviorAvailable Actions
activeFully operational. Customers can sign up, deploy GPUs, and pay.Suspend, Edit
sandboxNo Stripe keys configured. Full site works except checkout/deploy (no Stripe publishable key = no Stripe.js).Activate (add Stripe keys), Edit
suspendedTemporarily disabled. Dashboard shows a maintenance notice to customers.Activate, Edit
inactiveCompletely disabled. Not serving any requests for this tenant.Activate, Delete

Editing a Tenant

Click a tenant row in the admin panel to open the detail view. From there you can:

  • Update brand settings (name, logo, colors, custom CSS)
  • Add or remove domains (each triggers a new DNS verification)
  • Update Stripe keys (to switch from test to live, or rotate keys)
  • Adjust GPU pricing and enable/disable GPU types
  • Change support email and webhook URLs
  • Configure drip email sequences
  • Suspend, activate, or deactivate the tenant
  • Regenerate the API key (old key is invalidated immediately)

Preview URLs

Every tenant gets an instant preview URL at:

https://{slug}.tenants.packet.ai
  • Works immediately after tenant creation -- no DNS changes needed
  • Works for all tenant statuses including sandbox mode
  • Uses wildcard SSL via Let's Encrypt DNS challenge with Cloudflare
  • No domain verification is needed -- preview subdomains are resolved by slug directly
  • Useful for demos, sales presentations, and letting providers see their branding before configuring DNS
Example: A tenant with slug acme-gpu is immediately accessible at https://acme-gpu.tenants.packet.ai.

Sandbox Mode

Sandbox mode creates a fully functional tenant without Stripe keys. Useful for:

  • Demos and sales presentations
  • Letting providers test domain and branding before setting up Stripe
  • Internal testing of new features
  • Development and staging environments

How It Works

  1. Toggle “Sandbox Mode” in Step 3 of the onboarding wizard
  2. Stripe key fields become optional and are cleared
  3. The API route accepts sandboxMode: true and skips Stripe key validation
  4. Tenant is created with status sandbox and empty stripePublishableKey / stripeSecretKey

What Works in Sandbox

FeatureStatus
Dashboard, branding, navigationWorks normally
Widget pricing displayWorks (uses TenantGpuPricing data)
Status pageWorks normally
API docsWorks normally
DocumentationWorks normally
Checkout / deploy buttonsDisabled (no Stripe publishable key = no Stripe.js)
Payment processingDisabled

Converting Sandbox to Live

  1. Open the tenant in the admin panel
  2. Edit the Stripe configuration -- add the publishable key and secret key
  3. Optionally add the webhook secret
  4. Save. The tenant status changes to active and checkout is enabled.

Domain Verification

Custom domains require a DNS CNAME record pointing to tenants.packet.ai. The verification logic lives in src/lib/tenant/domain-verification.ts.

DNS Setup Instructions (for providers)

What to tell the provider:

  1. Log in to your DNS provider (Cloudflare, Route53, GoDaddy, etc.)
  2. Add a CNAME record for your subdomain pointing to tenants.packet.ai
  3. If using Cloudflare, set the proxy to “DNS only” (gray cloud) -- we handle TLS ourselves
  4. Wait 5-30 minutes for DNS propagation
  5. We auto-provision a TLS certificate via Let's Encrypt after verification
# Example DNS record gpu.acme.com CNAME tenants.packet.ai # Verify DNS from command line dig CNAME gpu.acme.com +short # Should return: tenants.packet.ai.

Verification Process

When verification is triggered (from the admin panel), the system performs a DNS CNAME lookup on the domain. If the CNAME resolves to tenants.packet.ai, the TenantDomain.verifiedAt field is set to the current timestamp. Only verified domains (where verifiedAt is not null) are used for tenant resolution.

Cloudflare proxy: If the provider has Cloudflare proxy enabled (orange cloud), the CNAME lookup will fail because Cloudflare rewrites DNS responses. They must use “DNS only” mode (gray cloud) for the domain pointed at us.

SSL Provisioning

SSL certificate provisioning is automated. After a domain passes DNS verification, the system provisions a Let's Encrypt certificate and configures nginx automatically.

How It Works

  1. DNS verification passes -- TenantDomain.verifiedAt is set
  2. SSL script triggered -- src/lib/tenant/provision-ssl.ts calls the server script via child_process.exec
  3. Server script runs -- /usr/local/bin/provision-tenant-ssl on the server:
    • Verifies DNS one more time
    • Runs certbot to obtain a Let's Encrypt certificate
    • Generates a per-domain nginx server block config
    • Reloads nginx

Cron Fallback

A cron endpoint at /api/cron/provision-tenant-ssl scans all verified domains that don't yet have SSL configured and provisions certificates for them. This catches any domains where the initial automated provisioning failed.

Preview URL SSL

Preview URLs (*.tenants.packet.ai) use a wildcard SSL certificate provisioned via Let's Encrypt DNS challenge with Cloudflare. This is separate from per-domain certificates and covers all preview subdomains automatically.

# Manual SSL provisioning (on server) sudo /usr/local/bin/provision-tenant-ssl gpu.acme.com # Check certificate status sudo certbot certificates | grep -A 5 "gpu.acme.com" # Check nginx config was generated ls -la /etc/nginx/sites-enabled/ | grep acme

Monitoring Margins

The Tenant Margin Dashboard (src/app/admin/components/TenantMarginDashboard.tsx) is accessible from the admin panel by clicking into a tenant's detail view. It shows:

  • Revenue -- Total customer payments to the tenant's Stripe account
  • Wholesale cost -- Our cost basis for GPU hours consumed (wholesaleCostCents)
  • Margin -- Revenue minus wholesale cost
  • Margin % -- Effective markup percentage
  • Per-GPU breakdown -- Margin by GPU type with utilization hours
Healthy margins: Most providers set 40-60% markup. If a tenant's margin is below 20%, consider reaching out to discuss pricing optimization.

Drip Email Configuration

Configured in src/app/admin/components/TenantDripConfig.tsx. When lead tracking is enabled, the system sends automated emails to new signups on behalf of the tenant:

  1. Welcome email -- Immediately after signup. Uses tenant branding and support email as the sender.
  2. Getting started -- 24 hours later. Links to the tenant's docs and deploy page.
  3. First deploy reminder -- 3 days later, only if the customer hasn't deployed yet.
Deliverability: Emails are sent from the tenant's support email address. The provider must have SPF and DKIM configured on their domain for reliable delivery. Without proper email authentication, drip emails may land in spam.

Embeddable Widgets

The widget system allows tenants to embed GPU pricing, checkout, authentication, and status components on their own website. Widgets render inside Shadow DOM for style isolation.

Available Widgets

WidgetDescription
pricingGPU pricing table with the tenant's configured rates
checkoutFull checkout flow (requires active Stripe keys)
authLogin/signup form for tenant customers
gpu-statusLive GPU availability and status display

Widget API Endpoints

Widgets are powered by the following API routes:

  • /api/widget/config -- Returns tenant configuration for widget initialization
  • /api/widget/pricing -- Returns GPU pricing data for the tenant
  • /api/widget/gpu-status -- Returns current GPU availability
  • /api/widget/auth -- Handles authentication for widget users

The widget JavaScript bundle is served from src/widget/. The script URL must match the tenant's domain for CORS to work correctly.

REST API

Tenants can integrate programmatically using the REST API. All endpoints require the X-API-Key header with a valid tenant API key. Authentication logic is in src/lib/tenant/api-auth.ts.

Endpoints

EndpointMethodsDescription
/api/v1/tenant/customersGET, POSTList and create tenant customers
/api/v1/tenant/podsGET, POSTList pods and create new deployments
/api/v1/tenant/pods/[podId]GET, PATCH, DELETEGet, update, or delete a specific pod
/api/v1/tenant/usageGETUsage data and billing summary

Example: List Customers

curl -H "X-API-Key: pak_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" \ https://gpu.acme.com/api/v1/tenant/customers

Example: Create a Pod

curl -X POST \ -H "X-API-Key: pak_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" \ -H "Content-Type: application/json" \ -d '{ "customerId": "cust_123", "gpuType": "rtx-pro-6000", "sshKey": "ssh-ed25519 AAAA..." }' \ https://gpu.acme.com/api/v1/tenant/pods

API Key Format

  • Format: pak_ + 32 hex characters (generated via crypto.randomBytes(16))
  • Generated during tenant creation (Step 6) and shown only once
  • Can be regenerated from the admin panel -- old key is invalidated immediately
  • Stored in plaintext in the database (hashing would prevent lookup)

Webhook Events

Tenants can configure two webhook URLs to receive event notifications. Webhook logic is in src/lib/tenant/webhooks.ts.

Lead Webhook (leadWebhookUrl)

Fires on the following events:

EventTrigger
lead.signupNew customer registers on the tenant's site
lead.first_deployCustomer deploys their first GPU pod
lead.paymentFirst successful payment from a customer

Alert Webhook (alertWebhookUrl)

Fires on the following events:

EventTrigger
alert.gpu_downA GPU instance went offline unexpectedly
alert.high_usageA customer is approaching their budget limit

Webhook Payload Format

// POST to leadWebhookUrl or alertWebhookUrl { "event": "lead.signup", "tenant": "acme-gpu", "data": { "email": "user@example.com", "name": "Jane Doe", "timestamp": "2025-01-15T10:30:00Z" } }

Delivery Behavior

  • Both endpoints expect a 200 response within 5 seconds
  • Failed deliveries are retried 3 times with exponential backoff
  • If all retries fail, the event is logged but not re-queued

WHMCS Module

The WHMCS provisioning module allows hosting providers to manage GPU pod lifecycle from within WHMCS. Located at whmcs-module/modules/servers/packetai/.

Download WHMCS Module (.zip)

Server Configuration

  • Hostname -- The tenant's domain (e.g., gpu.acme.com)
  • API Key -- The tenant's pak_ API key

Product Configuration

  • GPU Type -- Dropdown of available GPU types for this tenant
  • Default SSH Key -- Optional default SSH public key for provisioning

Client Area Features

  • Pod status display with real-time updates
  • Start / stop / restart controls
  • SSH terminal access
  • Usage statistics

Admin Area Features

  • Pod details and metadata
  • Links to the Packet.ai dashboard for the pod

API Client

The PHP API client (PacketaiApi.php) calls the following endpoints:

  • /api/widget/config -- Connection test
  • /api/v1/tenant/pods -- Pod CRUD operations

A daily WHMCS cron collects usage data for billing reconciliation.

Security

Encryption at Rest

  • Stripe secret keys are encrypted with AES-256-GCM using the encrypt() / decrypt() functions in src/lib/crypto
  • Stripe webhook secrets are also encrypted with AES-256-GCM
  • The encryption key is stored in the ENCRYPTION_KEY environment variable
  • Publishable keys are stored in plaintext (they are public by design)

Admin Access Control

  • All tenant admin endpoints require a valid admin_session cookie
  • Admin auth uses JWT with mandatory 2FA (TOTP)
  • The admin CRUD API is at src/app/api/admin/tenants/route.ts
  • Stripe secret keys in API responses are always redacted as [encrypted]

Tenant API Access

  • All /api/v1/tenant/* endpoints validate the X-API-Key header
  • Key validation logic is in src/lib/tenant/api-auth.ts
  • API key format: pak_ + 32 hex characters (crypto.randomBytes(16))
  • Keys can be regenerated from the admin panel -- old key is invalidated immediately

Stripe Key Rotation

  1. Open the tenant in the admin panel
  2. Click “Edit” on the Stripe section
  3. Enter the new publishable key, secret key, and webhook secret
  4. Save. The old keys are overwritten immediately.
  5. Verify by making a test purchase on the tenant's domain
Warning: Key rotation is immediate. Any in-flight payments using the old keys will fail. Coordinate with the provider to rotate during a low-traffic window.

Key Files Reference

Core Tenant System

FilePurpose
src/middleware.tsSets x-tenant-host header from request hostname
src/lib/tenant/resolve.tsDomain-to-tenant resolution with 5-minute in-memory cache
src/lib/tenant/types.tsTenantConfig TypeScript type definition
src/lib/tenant/constants.tsDefault hostnames and tenant constants
src/lib/tenant/context.tsReact context for tenant config
src/lib/tenant/index.tsMain tenant module exports
src/lib/tenant/api-auth.tsX-API-Key validation for tenant API endpoints
src/lib/tenant/domain-verification.tsDNS CNAME verification logic
src/lib/tenant/provision-ssl.tsAutomated SSL provisioning (calls server script via child_process.exec)
src/lib/tenant/leads.tsLead tracking and drip email triggers
src/lib/tenant/webhooks.tsWebhook event dispatch with retry logic
src/lib/tenant/metadata.tsTenant metadata utilities

UI Components

FilePurpose
src/components/TenantStyles.tsxInjects CSS custom properties (:root) with tenant colors + customCss
src/components/TenantBrand.tsxExports TenantLogo() and TenantName() components

Admin Components

FilePurpose
src/app/admin/components/TenantsTab.tsxMain tenant list and detail view in admin panel
src/app/admin/components/TenantOnboardingWizard.tsx6-step tenant creation wizard
src/app/admin/components/TenantMarginDashboard.tsxRevenue, cost, and margin analytics
src/app/admin/components/TenantDripConfig.tsxDrip email sequence configuration

API Routes

RoutePurpose
src/app/api/admin/tenants/route.tsAdmin CRUD API for tenants
src/app/api/v1/tenant/customers/route.tsTenant customer management API
src/app/api/v1/tenant/pods/route.tsTenant pod list and creation API
src/app/api/v1/tenant/pods/[podId]/route.tsIndividual pod management API
src/app/api/v1/tenant/usage/route.tsUsage data and billing summary API
src/app/api/widget/configWidget configuration endpoint
src/app/api/widget/pricingWidget pricing data endpoint
src/app/api/widget/gpu-statusWidget GPU availability endpoint
src/app/api/widget/authWidget authentication endpoint
src/app/api/cron/provision-tenant-sslCron fallback for SSL provisioning

Other

File/PathPurpose
src/lib/cryptoAES-256-GCM encrypt/decrypt for Stripe keys
src/widget/Widget JavaScript bundle source
whmcs-module/modules/servers/packetai/WHMCS provisioning module (PHP)
/usr/local/bin/provision-tenant-sslServer-side SSL provisioning script (on production server)

Troubleshooting

Domain not resolving to tenant

Symptoms

Custom domain shows the default Packet.ai site instead of the tenant's branded site.

Checks

  1. Verify the CNAME is set correctly:
    dig CNAME gpu.acme.com +short # Expected: tenants.packet.ai.
  2. Check if the TenantDomain record exists and has verifiedAt set (not null)
  3. If using Cloudflare, confirm proxy is disabled (gray cloud, not orange)
  4. Check for CAA records that might block Let's Encrypt:
    dig CAA acme.com +short # If CAA records exist, ensure "letsencrypt.org" is allowed
  5. Remember the 5-minute in-memory cache -- if you just verified the domain, wait for cache expiry or restart the process
  6. DNS propagation can take up to 48 hours in rare cases

SSL certificate not provisioning

Checks

  1. Confirm the domain's DNS verification passed (verifiedAt is not null)
  2. SSH into the server and run the provisioning script manually:
    sudo /usr/local/bin/provision-tenant-ssl gpu.acme.com
  3. Check certbot logs: /var/log/letsencrypt/letsencrypt.log
  4. Verify nginx config was generated in /etc/nginx/sites-enabled/
  5. The cron fallback at /api/cron/provision-tenant-ssl will also attempt to provision -- check its logs

Stripe payments failing

Checks

  • Confirm the provider is using the correct key pair (test vs. live)
  • Check the Stripe dashboard for error logs under the provider's account
  • Verify the webhook URL is correct and returning 200
  • For “No such customer” errors, the provider may have switched Stripe accounts -- customer IDs are not portable between accounts
  • For sandbox tenants, checkout is expected to be disabled -- this is not an error

Widget not loading on provider's site

Checks

  • Check the script URL matches the tenant's primary domain
  • Verify the domain is resolving correctly and the TLS cert is valid
  • Check browser console for CORS errors -- the widget script must be served from the tenant's domain
  • Confirm the tenant status is active (not suspended or inactive)
  • Test the widget config endpoint directly: https://gpu.acme.com/api/widget/config

API returning 401 Unauthorized

Checks

  • Verify the API key starts with pak_ and is 36 characters total
  • Confirm the X-API-Key header is being sent (not Authorization)
  • Check if the key was regenerated -- the old key is invalidated immediately upon regeneration
  • Confirm the tenant is in active or sandbox status
  • Test with curl:
    curl -v -H "X-API-Key: pak_..." https://gpu.acme.com/api/v1/tenant/customers

Webhooks not being received

Checks

  • Verify the webhook URL is accessible from the internet (not localhost)
  • The endpoint must return a 200 status within 5 seconds
  • After 3 failed retries with exponential backoff, the event is dropped
  • Check the tenant's leadWebhookUrl and alertWebhookUrl fields in the admin panel
  • Test the endpoint manually with curl:
    curl -X POST -H "Content-Type: application/json" \ -d '{"event":"lead.signup","tenant":"test","data":{}}' \ https://provider-endpoint.example.com/webhook

Branding changes not appearing

Checks

  • Tenant config is cached in-memory for 5 minutes. Wait for cache expiry or restart the process.
  • Hard-refresh the browser (Cmd+Shift+R) to clear browser cache
  • Verify the changes were actually saved by re-opening the tenant in the admin panel

WHMCS module connection test failing

Checks

  • The WHMCS server must be able to reach the tenant's domain over HTTPS
  • Verify the hostname in WHMCS server config matches the tenant's domain exactly
  • Verify the API key in WHMCS server config is correct
  • The connection test calls /api/widget/config -- test this endpoint directly from the WHMCS server