Skip to content

Latest commit

 

History

History
597 lines (483 loc) · 13.7 KB

File metadata and controls

597 lines (483 loc) · 13.7 KB

Zerostart API Documentation

Base URL: https://zerostart-production.up.railway.app

Authentication

All /api/* endpoints require a Supabase JWT token in the Authorization header:

Authorization: Bearer {supabase_access_token}

Getting the Token (Frontend)

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;

Endpoints

Health Check

GET /health

Auth: None

Response:

{
  "status": "ok",
  "timestamp": "2026-02-01T04:41:37Z"
}

Create Droplet

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 York
  • sfo3 - San Francisco
  • ams3 - Amsterdam
  • sgp1 - Singapore
  • lon1 - London
  • fra1 - Frankfurt
  • tor1 - Toronto
  • blr1 - Bangalore
  • syd1 - 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 User's Droplet

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 User's Droplet

DELETE /api/droplets/me

Auth: Required

Response (200 OK):

{
  "message": "Droplet destroyed successfully"
}

Response (404 Not Found):

{
  "error": "No droplet found for user"
}

Frontend Implementation Guide

1. Environment Variables

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

2. API Client Helper

// 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();
}

3. Polling for Droplet Status

// 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) };
}

4. Accessing OpenClaw Dashboard

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:

  1. OpenClaw uses HTTPS on port 443 - The Moltbot image includes Caddy with auto-SSL
  2. First-time setup required - User will see a setup wizard to select AI provider and enter API key
  3. Browser may show SSL warning - Self-signed cert on IP access; subdomain URL will have valid Let's Encrypt cert once configured

5. Embedding OpenClaw (iFrame)

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>
  );
}

6. Launch Button (External Tab)

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>
  );
}

7. Complete Dashboard Component

'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>
  );
}

Supabase Configuration

Required Settings

Authentication > URL Configuration:

Site URL: https://zerostart.cloud
Redirect URLs:
  - https://zerostart.cloud/auth/callback
  - https://zerostart.cloud
  - http://localhost:3000/auth/callback

Database Schema

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 Handling

Common Errors

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

Frontend Error Handling

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
  }
}

Rate Limits

  • Create droplet: 1 per user (enforced by backend)
  • API requests: 100/minute per user
  • Polling: Recommend 5-second intervals

Support