Base URL: https://zerostart-production.up.railway.app
All /api/* endpoints require a Supabase JWT token in the Authorization header:
Authorization: Bearer {supabase_access_token}
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
// After user login
const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token;GET /health
Auth: None
Response:
{
"status": "ok",
"timestamp": "2026-02-01T04:41:37Z"
}POST /api/droplets
Auth: Required
Request Body:
{
"subdomain": "zerocool",
"region": "nyc3"
}| Field | Type | Required | Description |
|---|---|---|---|
subdomain |
string | Yes | Unique subdomain (alphanumeric, lowercase, 3-20 chars) |
region |
string | No | DO region (default: nyc3) |
Available Regions:
nyc1,nyc3- New Yorksfo3- San Franciscoams3- Amsterdamsgp1- Singaporelon1- Londonfra1- Frankfurttor1- Torontoblr1- Bangaloresyd1- Sydney
Response (201 Created):
{
"id": "47c665d4-311f-4702-a872-53401f4dbf1b",
"user_id": "a5a16779-b685-4515-96ab-911c80779dac",
"droplet_id": 548619964,
"name": "zerostart-zerocool",
"subdomain": "zerocool",
"ip_address": null,
"status": "provisioning",
"region": "nyc3",
"size": "s-2vcpu-4gb",
"created_at": "2026-02-01T04:41:37Z",
"updated_at": "2026-02-01T04:41:37Z"
}Error Responses:
| Status | Reason |
|---|---|
| 400 | Missing subdomain or invalid format |
| 401 | Missing or invalid auth token |
| 409 | Subdomain already taken |
| 500 | Server error |
GET /api/droplets/me
Auth: Required
Response (200 OK):
{
"id": "47c665d4-311f-4702-a872-53401f4dbf1b",
"user_id": "a5a16779-b685-4515-96ab-911c80779dac",
"droplet_id": 548619964,
"name": "zerostart-zerocool",
"subdomain": "zerocool",
"ip_address": "68.183.146.231",
"status": "active",
"region": "nyc3",
"size": "s-2vcpu-4gb",
"created_at": "2026-02-01T04:41:37Z",
"updated_at": "2026-02-01T04:42:15Z"
}Response (404 Not Found):
{
"error": "No droplet found for user"
}Droplet Status Values:
| Status | Description |
|---|---|
provisioning |
Droplet is being created |
active |
Droplet is running and ready |
error |
Creation failed |
destroyed |
Droplet has been deleted |
DELETE /api/droplets/me
Auth: Required
Response (200 OK):
{
"message": "Droplet destroyed successfully"
}Response (404 Not Found):
{
"error": "No droplet found for user"
}NEXT_PUBLIC_SUPABASE_URL=https://apjyjuueemookvgihmta.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key
NEXT_PUBLIC_API_URL=https://zerostart-production.up.railway.app// lib/api.ts
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
const API_URL = process.env.NEXT_PUBLIC_API_URL;
async function getAuthHeaders() {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
throw new Error('Not authenticated');
}
return {
'Authorization': `Bearer ${session.access_token}`,
'Content-Type': 'application/json',
};
}
export async function createDroplet(subdomain: string, region?: string) {
const headers = await getAuthHeaders();
const response = await fetch(`${API_URL}/api/droplets`, {
method: 'POST',
headers,
body: JSON.stringify({ subdomain, region }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to create droplet');
}
return response.json();
}
export async function getMyDroplet() {
const headers = await getAuthHeaders();
const response = await fetch(`${API_URL}/api/droplets/me`, {
method: 'GET',
headers,
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error('Failed to fetch droplet');
}
return response.json();
}
export async function deleteMyDroplet() {
const headers = await getAuthHeaders();
const response = await fetch(`${API_URL}/api/droplets/me`, {
method: 'DELETE',
headers,
});
if (!response.ok) {
throw new Error('Failed to delete droplet');
}
return response.json();
}// hooks/useDropletStatus.ts
import { useState, useEffect } from 'react';
import { getMyDroplet } from '@/lib/api';
interface Droplet {
id: string;
subdomain: string;
ip_address: string | null;
status: 'provisioning' | 'active' | 'error' | 'destroyed';
region: string;
created_at: string;
}
export function useDropletStatus(pollInterval = 5000) {
const [droplet, setDroplet] = useState<Droplet | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let isMounted = true;
let intervalId: NodeJS.Timeout;
async function fetchDroplet() {
try {
const data = await getMyDroplet();
if (isMounted) {
setDroplet(data);
setLoading(false);
// Stop polling once active or error
if (data?.status === 'active' || data?.status === 'error') {
clearInterval(intervalId);
}
}
} catch (err) {
if (isMounted) {
setError(err instanceof Error ? err.message : 'Unknown error');
setLoading(false);
}
}
}
fetchDroplet();
intervalId = setInterval(fetchDroplet, pollInterval);
return () => {
isMounted = false;
clearInterval(intervalId);
};
}, [pollInterval]);
return { droplet, loading, error, refetch: () => getMyDroplet().then(setDroplet) };
}Once the droplet is active and has an ip_address, users can access OpenClaw at:
// Direct IP access (recommended for MVP)
const openclawUrl = `https://${droplet.ip_address}`;
// Via subdomain (after DNS propagates, ~2-5 minutes)
const subdomainUrl = `https://${droplet.subdomain}.zerostart.cloud`;Important Notes:
- OpenClaw uses HTTPS on port 443 - The Moltbot image includes Caddy with auto-SSL
- First-time setup required - User will see a setup wizard to select AI provider and enter API key
- Browser may show SSL warning - Self-signed cert on IP access; subdomain URL will have valid Let's Encrypt cert once configured
interface OpenClawEmbedProps {
ipAddress: string;
}
export function OpenClawEmbed({ ipAddress }: OpenClawEmbedProps) {
const [loading, setLoading] = useState(true);
return (
<div className="relative w-full h-screen">
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-green-400"></div>
</div>
)}
<iframe
src={`https://${ipAddress}`}
className="w-full h-full border-0"
onLoad={() => setLoading(false)}
allow="camera; microphone; clipboard-write; clipboard-read"
sandbox="allow-same-origin allow-scripts allow-popups allow-forms allow-modals"
/>
</div>
);
}interface LaunchButtonProps {
ipAddress: string;
subdomain: string;
}
export function LaunchButton({ ipAddress, subdomain }: LaunchButtonProps) {
const handleLaunch = () => {
// Use IP for immediate access, subdomain after DNS propagates
window.open(`https://${ipAddress}`, '_blank');
};
return (
<button
onClick={handleLaunch}
className="flex items-center gap-2 px-6 py-3 bg-green-500 hover:bg-green-600 text-black font-bold rounded-lg transition-colors"
>
<ExternalLinkIcon className="w-5 h-5" />
Launch OpenClaw
</button>
);
}'use client';
import { useState } from 'react';
import { useDropletStatus } from '@/hooks/useDropletStatus';
import { createDroplet, deleteMyDroplet } from '@/lib/api';
export function Dashboard() {
const { droplet, loading, error, refetch } = useDropletStatus();
const [subdomain, setSubdomain] = useState('');
const [creating, setCreating] = useState(false);
const [deleting, setDeleting] = useState(false);
const handleCreate = async () => {
if (!subdomain) return;
setCreating(true);
try {
await createDroplet(subdomain);
refetch();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to create');
} finally {
setCreating(false);
}
};
const handleDelete = async () => {
if (!confirm('Are you sure? This will destroy your instance.')) return;
setDeleting(true);
try {
await deleteMyDroplet();
refetch();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to delete');
} finally {
setDeleting(false);
}
};
if (loading) {
return <div>Loading...</div>;
}
// No droplet - show create form
if (!droplet) {
return (
<div className="space-y-4">
<h1>Create Your OpenClaw Instance</h1>
<input
type="text"
placeholder="Choose a subdomain"
value={subdomain}
onChange={(e) => setSubdomain(e.target.value.toLowerCase())}
pattern="[a-z0-9]+"
className="px-4 py-2 border rounded"
/>
<button
onClick={handleCreate}
disabled={creating || !subdomain}
className="px-6 py-2 bg-green-500 text-white rounded disabled:opacity-50"
>
{creating ? 'Creating...' : 'Create Instance'}
</button>
</div>
);
}
// Droplet provisioning
if (droplet.status === 'provisioning') {
return (
<div className="space-y-4">
<h1>Setting Up Your Instance</h1>
<p>{droplet.subdomain}.zerostart.cloud</p>
<div className="animate-pulse">Provisioning... This takes 2-3 minutes.</div>
</div>
);
}
// Droplet active
if (droplet.status === 'active' && droplet.ip_address) {
return (
<div className="space-y-4">
<h1>Your OpenClaw Instance</h1>
<div className="p-4 bg-gray-800 rounded-lg">
<p><strong>Subdomain:</strong> {droplet.subdomain}.zerostart.cloud</p>
<p><strong>IP Address:</strong> {droplet.ip_address}</p>
<p><strong>Region:</strong> {droplet.region}</p>
<p><strong>Status:</strong> <span className="text-green-400">● Active</span></p>
</div>
<div className="flex gap-4">
<button
onClick={() => window.open(`https://${droplet.ip_address}`, '_blank')}
className="px-6 py-2 bg-green-500 text-black font-bold rounded"
>
Launch OpenClaw
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="px-6 py-2 bg-red-500 text-white rounded disabled:opacity-50"
>
{deleting ? 'Destroying...' : 'Destroy Instance'}
</button>
</div>
</div>
);
}
// Error state
return (
<div className="space-y-4">
<h1>Error</h1>
<p>Something went wrong. Please try again.</p>
<button onClick={handleDelete} className="px-6 py-2 bg-red-500 text-white rounded">
Clear & Retry
</button>
</div>
);
}Authentication > URL Configuration:
Site URL: https://zerostart.cloud
Redirect URLs:
- https://zerostart.cloud/auth/callback
- https://zerostart.cloud
- http://localhost:3000/auth/callback
CREATE TABLE droplets (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id TEXT NOT NULL,
droplet_id BIGINT NOT NULL DEFAULT 0,
name TEXT NOT NULL,
ip_address TEXT,
subdomain TEXT NOT NULL,
region TEXT NOT NULL DEFAULT 'nyc3',
size TEXT NOT NULL DEFAULT 's-2vcpu-4gb',
status TEXT NOT NULL DEFAULT 'provisioning',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE UNIQUE INDEX droplets_subdomain_active_unique
ON droplets (subdomain)
WHERE status IN ('provisioning', 'active');
CREATE INDEX idx_droplets_user_id ON droplets(user_id);| Error | Cause | Solution |
|---|---|---|
| 401 Unauthorized | Invalid or expired token | Re-authenticate user |
| 409 Conflict | Subdomain taken | Choose different subdomain |
| 500 Internal Error | Server issue | Retry or contact support |
try {
const droplet = await createDroplet(subdomain);
} catch (error) {
if (error.message.includes('subdomain')) {
// Show "subdomain taken" message
} else if (error.message.includes('401')) {
// Redirect to login
} else {
// Show generic error
}
}- Create droplet: 1 per user (enforced by backend)
- API requests: 100/minute per user
- Polling: Recommend 5-second intervals
- API Issues: Check Railway logs
- OpenClaw Issues: https://docs.openclaw.ai/
- Supabase Issues: Check Supabase dashboard