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-Keyauthentication - Preview URLs at
{slug}.tenants.packet.aifor 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.
| Model | Purpose | Key Fields |
|---|---|---|
Tenant | Master record for each tenant | brandName, slug, status, colors, Stripe keys, support config, GPU config |
TenantDomain | Domains per tenant | domain, isPrimary, verifiedAt |
TenantGpuPricing | Per-GPU pricing configuration | gpuType, hourlyRateCents, monthlyRateCents, wholesaleCostCents |
TenantCustomer | Customers belonging to a tenant | email, 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
- Default hostnames --
localhost,dash.packet.ai,packet.ai, andtenants.packet.aiall resolve to the default Packet.ai config. No database lookup. - Preview subdomains -- Hostnames matching
{slug}.tenants.packet.aitrigger a lookup by slug. No domain verification is needed for preview URLs. - Custom domains -- All other hostnames trigger a
TenantDomainlookup. The domain record must haveverifiedAtset (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.
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
| Field | Purpose | Notes |
|---|---|---|
brandName | Display name | Shown in dashboard header, emails, page titles |
logoUrl | Logo image | Direct URL to SVG or PNG. Used in header and widgets. |
faviconUrl | Favicon | Optional. Falls back to Packet.ai default. |
ogImageUrl | Open Graph image | Used for social media previews |
primaryColor | Primary brand color | Buttons, links, active states. Hex format. |
accentColor | Accent color | Badges, highlights, secondary actions |
bgColor | Background color | Page background |
textColor | Text color | Body text |
customCss | Raw CSS | Injected 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_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 = nulland are verified separately (see Domain Verification section).
{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_orpk_live_. Stored in plaintext (public by design). - Secret Key -- Starts with
sk_test_orsk_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: trueand 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
TenantGpuPricingrecord.
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:
- Creates the
Tenantrecord with all brand, Stripe, and support config - Creates
TenantDomainrecords for each configured domain - Creates
TenantGpuPricingrecords for each selected GPU - Generates an API key (
pak_+ 32 hex chars viacrypto.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 viewsrc/app/admin/components/TenantOnboardingWizard.tsx-- 6-step creation wizardsrc/app/admin/components/TenantMarginDashboard.tsx-- revenue and margin analyticssrc/app/admin/components/TenantDripConfig.tsx-- drip email configuration
Tenant Statuses
| Status | Behavior | Available Actions |
|---|---|---|
active | Fully operational. Customers can sign up, deploy GPUs, and pay. | Suspend, Edit |
sandbox | No Stripe keys configured. Full site works except checkout/deploy (no Stripe publishable key = no Stripe.js). | Activate (add Stripe keys), Edit |
suspended | Temporarily disabled. Dashboard shows a maintenance notice to customers. | Activate, Edit |
inactive | Completely 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:
- 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
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
- Toggle “Sandbox Mode” in Step 3 of the onboarding wizard
- Stripe key fields become optional and are cleared
- The API route accepts
sandboxMode: trueand skips Stripe key validation - Tenant is created with status
sandboxand emptystripePublishableKey/stripeSecretKey
What Works in Sandbox
| Feature | Status |
|---|---|
| Dashboard, branding, navigation | Works normally |
| Widget pricing display | Works (uses TenantGpuPricing data) |
| Status page | Works normally |
| API docs | Works normally |
| Documentation | Works normally |
| Checkout / deploy buttons | Disabled (no Stripe publishable key = no Stripe.js) |
| Payment processing | Disabled |
Converting Sandbox to Live
- Open the tenant in the admin panel
- Edit the Stripe configuration -- add the publishable key and secret key
- Optionally add the webhook secret
- Save. The tenant status changes to
activeand 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:
- Log in to your DNS provider (Cloudflare, Route53, GoDaddy, etc.)
- Add a CNAME record for your subdomain pointing to
tenants.packet.ai - If using Cloudflare, set the proxy to “DNS only” (gray cloud) -- we handle TLS ourselves
- Wait 5-30 minutes for DNS propagation
- We auto-provision a TLS certificate via Let's Encrypt after verification
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.
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
- DNS verification passes --
TenantDomain.verifiedAtis set - SSL script triggered --
src/lib/tenant/provision-ssl.tscalls the server script viachild_process.exec - Server script runs --
/usr/local/bin/provision-tenant-sslon the server:- Verifies DNS one more time
- Runs
certbotto 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.
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
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:
- Welcome email -- Immediately after signup. Uses tenant branding and support email as the sender.
- Getting started -- 24 hours later. Links to the tenant's docs and deploy page.
- First deploy reminder -- 3 days later, only if the customer hasn't deployed yet.
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
| Widget | Description |
|---|---|
pricing | GPU pricing table with the tenant's configured rates |
checkout | Full checkout flow (requires active Stripe keys) |
auth | Login/signup form for tenant customers |
gpu-status | Live 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
| Endpoint | Methods | Description |
|---|---|---|
/api/v1/tenant/customers | GET, POST | List and create tenant customers |
/api/v1/tenant/pods | GET, POST | List pods and create new deployments |
/api/v1/tenant/pods/[podId] | GET, PATCH, DELETE | Get, update, or delete a specific pod |
/api/v1/tenant/usage | GET | Usage data and billing summary |
Example: List Customers
Example: Create a Pod
API Key Format
- Format:
pak_+ 32 hex characters (generated viacrypto.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:
| Event | Trigger |
|---|---|
lead.signup | New customer registers on the tenant's site |
lead.first_deploy | Customer deploys their first GPU pod |
lead.payment | First successful payment from a customer |
Alert Webhook (alertWebhookUrl)
Fires on the following events:
| Event | Trigger |
|---|---|
alert.gpu_down | A GPU instance went offline unexpectedly |
alert.high_usage | A customer is approaching their budget limit |
Webhook Payload Format
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/.
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 insrc/lib/crypto - Stripe webhook secrets are also encrypted with AES-256-GCM
- The encryption key is stored in the
ENCRYPTION_KEYenvironment variable - Publishable keys are stored in plaintext (they are public by design)
Admin Access Control
- All tenant admin endpoints require a valid
admin_sessioncookie - 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 theX-API-Keyheader - 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
- Open the tenant in the admin panel
- Click “Edit” on the Stripe section
- Enter the new publishable key, secret key, and webhook secret
- Save. The old keys are overwritten immediately.
- Verify by making a test purchase on the tenant's domain
Key Files Reference
Core Tenant System
| File | Purpose |
|---|---|
src/middleware.ts | Sets x-tenant-host header from request hostname |
src/lib/tenant/resolve.ts | Domain-to-tenant resolution with 5-minute in-memory cache |
src/lib/tenant/types.ts | TenantConfig TypeScript type definition |
src/lib/tenant/constants.ts | Default hostnames and tenant constants |
src/lib/tenant/context.ts | React context for tenant config |
src/lib/tenant/index.ts | Main tenant module exports |
src/lib/tenant/api-auth.ts | X-API-Key validation for tenant API endpoints |
src/lib/tenant/domain-verification.ts | DNS CNAME verification logic |
src/lib/tenant/provision-ssl.ts | Automated SSL provisioning (calls server script via child_process.exec) |
src/lib/tenant/leads.ts | Lead tracking and drip email triggers |
src/lib/tenant/webhooks.ts | Webhook event dispatch with retry logic |
src/lib/tenant/metadata.ts | Tenant metadata utilities |
UI Components
| File | Purpose |
|---|---|
src/components/TenantStyles.tsx | Injects CSS custom properties (:root) with tenant colors + customCss |
src/components/TenantBrand.tsx | Exports TenantLogo() and TenantName() components |
Admin Components
| File | Purpose |
|---|---|
src/app/admin/components/TenantsTab.tsx | Main tenant list and detail view in admin panel |
src/app/admin/components/TenantOnboardingWizard.tsx | 6-step tenant creation wizard |
src/app/admin/components/TenantMarginDashboard.tsx | Revenue, cost, and margin analytics |
src/app/admin/components/TenantDripConfig.tsx | Drip email sequence configuration |
API Routes
| Route | Purpose |
|---|---|
src/app/api/admin/tenants/route.ts | Admin CRUD API for tenants |
src/app/api/v1/tenant/customers/route.ts | Tenant customer management API |
src/app/api/v1/tenant/pods/route.ts | Tenant pod list and creation API |
src/app/api/v1/tenant/pods/[podId]/route.ts | Individual pod management API |
src/app/api/v1/tenant/usage/route.ts | Usage data and billing summary API |
src/app/api/widget/config | Widget configuration endpoint |
src/app/api/widget/pricing | Widget pricing data endpoint |
src/app/api/widget/gpu-status | Widget GPU availability endpoint |
src/app/api/widget/auth | Widget authentication endpoint |
src/app/api/cron/provision-tenant-ssl | Cron fallback for SSL provisioning |
Other
| File/Path | Purpose |
|---|---|
src/lib/crypto | AES-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-ssl | Server-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
- Verify the CNAME is set correctly:dig CNAME gpu.acme.com +short # Expected: tenants.packet.ai.
- Check if the
TenantDomainrecord exists and hasverifiedAtset (not null) - If using Cloudflare, confirm proxy is disabled (gray cloud, not orange)
- Check for CAA records that might block Let's Encrypt:dig CAA acme.com +short # If CAA records exist, ensure "letsencrypt.org" is allowed
- Remember the 5-minute in-memory cache -- if you just verified the domain, wait for cache expiry or restart the process
- DNS propagation can take up to 48 hours in rare cases
SSL certificate not provisioning
Checks
- Confirm the domain's DNS verification passed (
verifiedAtis not null) - SSH into the server and run the provisioning script manually:sudo /usr/local/bin/provision-tenant-ssl gpu.acme.com
- Check certbot logs:
/var/log/letsencrypt/letsencrypt.log - Verify nginx config was generated in
/etc/nginx/sites-enabled/ - The cron fallback at
/api/cron/provision-tenant-sslwill 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(notsuspendedorinactive) - 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-Keyheader is being sent (notAuthorization) - Check if the key was regenerated -- the old key is invalidated immediately upon regeneration
- Confirm the tenant is in
activeorsandboxstatus - 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
leadWebhookUrlandalertWebhookUrlfields 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
