Get Panalo.ai live in under 2 hours
Step-by-step guide to deploy your Node.js/Express backend, connect Supabase and Stripe, and reach a live testing URL.
Architecture overview
The backend is a single Node.js/Express app that does four things: handles API requests, runs AI tasks through Anthropic, dispatches work to a Bull queue (Redis), and processes Stripe billing events.
Browser / Frontend HTML files
│
▼
Express API (Railway) ← Your backend
│
┌────┴────────────────────┐
│ │
Supabase DB Anthropic API
(PostgreSQL) (Claude Sonnet 4.6)
│ │
└────────┐ ┌────────────┘
│ │
Bull Queue (Redis) ← Async task processing
│
Email (Resend) ← Notifications
│
Stripe ← Billing webhooks
Prerequisites
You need accounts on four services — all have free tiers to get started.
Local setup
Step 1 — Get the backend code
The backend folder panalo-backend/ is included with your project files. Put it on GitHub first:
# Navigate to the backend folder cd panalo-backend # Initialize git git init git add . git commit -m "Initial Panalo.ai backend" # Create a new repo on github.com, then: git remote add origin https://github.com/YOUR_USERNAME/panalo-backend.git git branch -M main git push -u origin main
Step 2 — Install dependencies
cd panalo-backend npm install
Step 3 — Create your .env file
cp .env.example .env
Then open .env and fill in your keys (see Section 4 below).
Step 4 — Start the dev server
npm run dev
# You should see:
# 🚀 Panalo.ai backend running { port: 3001, env: 'development' }
Environment variables
Here's every variable you need, where to get it, and whether it's required to start.
| Variable | Where to get it | Required? |
|---|---|---|
| JWT_SECRET | Make up any random 32+ char string. Run: openssl rand -hex 32 | Required |
| JWT_REFRESH_SECRET | Another random string. Must be different from JWT_SECRET. | Required |
| SUPABASE_URL | Supabase Dashboard → Project Settings → API → Project URL | Required |
| SUPABASE_ANON_KEY | Supabase Dashboard → Project Settings → API → anon/public key | Required |
| SUPABASE_SERVICE_KEY | Supabase Dashboard → Project Settings → API → service_role key | Required |
| ANTHROPIC_API_KEY | console.anthropic.com/settings/keys | Required |
| STRIPE_SECRET_KEY | Stripe Dashboard → Developers → API Keys → Secret key (use sk_test_... for testing) | Required |
| STRIPE_WEBHOOK_SECRET | Stripe Dashboard → Developers → Webhooks → Signing secret (after you create a webhook) | Required |
| STRIPE_PRICE_STARTER | Stripe Dashboard → Products → Create your 3 plans → copy Price ID (price_...) | Required |
| REDIS_URL | Railway provides this automatically when you add a Redis plugin. Leave blank for dev (tasks process inline). | Optional |
| RESEND_API_KEY | resend.com → API Keys. Free tier = 3,000 emails/month. | Optional |
| FRONTEND_URL | Your Vercel or Netlify URL, or http://localhost:3000 for local dev | Required |
Database setup (Supabase)
Step 1 — Create a Supabase project
Step 2 — Run the database schema
Open the Supabase SQL Editor (left sidebar → SQL Editor → New Query) and paste this:
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- USERS
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL,
first_name TEXT NOT NULL, last_name TEXT NOT NULL,
company TEXT, role TEXT NOT NULL DEFAULT 'client',
plan TEXT NOT NULL DEFAULT 'starter',
subscription_status TEXT DEFAULT 'trialing',
stripe_customer_id TEXT UNIQUE, stripe_subscription_id TEXT UNIQUE,
subscription_renews_at TIMESTAMPTZ, task_count INTEGER DEFAULT 0,
token_version INTEGER DEFAULT 0, last_login TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- AGENTS
CREATE TABLE IF NOT EXISTS agents (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
name TEXT NOT NULL, email TEXT UNIQUE NOT NULL,
location TEXT DEFAULT 'Manila, PH', avatar_url TEXT,
role TEXT DEFAULT 'agent', status TEXT DEFAULT 'offline',
current_tasks INTEGER DEFAULT 0, completed_today INTEGER DEFAULT 0,
avg_handle_time_minutes INTEGER DEFAULT 8, csat_score NUMERIC(3,2) DEFAULT 5.0,
shift_start TIME, shift_end TIME,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- TASKS
CREATE TABLE IF NOT EXISTS tasks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
agent_id UUID REFERENCES agents(id) ON DELETE SET NULL,
title TEXT NOT NULL, description TEXT,
type TEXT DEFAULT 'other', complexity TEXT, priority TEXT DEFAULT 'normal',
status TEXT DEFAULT 'pending', handler TEXT, tags TEXT[] DEFAULT '{}',
due_date TIMESTAMPTZ, estimated_minutes INTEGER, ai_note TEXT,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_tasks_user_id ON tasks(user_id);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
CREATE INDEX IF NOT EXISTS idx_tasks_created_at ON tasks(created_at DESC);
-- TASK RESULTS
CREATE TABLE IF NOT EXISTS task_results (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
output TEXT NOT NULL, confidence INTEGER, steps_taken TEXT[],
handler TEXT, agent_id UUID REFERENCES agents(id) ON DELETE SET NULL,
rated INTEGER, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- MESSAGES
CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
sender_id UUID REFERENCES users(id) ON DELETE SET NULL,
sender_type TEXT NOT NULL, sender_name TEXT, body TEXT NOT NULL,
read_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_messages_task_id ON messages(task_id);
-- INVOICES
CREATE TABLE IF NOT EXISTS invoices (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
stripe_id TEXT UNIQUE, amount NUMERIC(10,2), currency TEXT DEFAULT 'usd',
status TEXT DEFAULT 'paid', pdf_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- PASSWORD RESET TOKENS
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT UNIQUE NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
used BOOLEAN DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- HELPER FUNCTIONS
CREATE OR REPLACE FUNCTION increment_agent_tasks(agent_id UUID)
RETURNS void LANGUAGE sql AS $$
UPDATE agents SET current_tasks = current_tasks + 1, updated_at = NOW() WHERE id = agent_id;
$$;
CREATE OR REPLACE FUNCTION decrement_agent_tasks(agent_id UUID)
RETURNS void LANGUAGE sql AS $$
UPDATE agents
SET current_tasks = GREATEST(0, current_tasks - 1),
completed_today = completed_today + 1, updated_at = NOW()
WHERE id = agent_id;
$$;
Click Run. You should see "Success. No rows returned."
Step 3 — Seed test data
npm run seed # Output: # ✅ Admin user: admin@panalo.ai / admin123! # ✅ Test client: sarah@buildco.com / client123! # ✅ 5 agents seeded # ✅ 4 sample tasks seeded
Deploy to Railway (live URL)
Railway gives you a live HTTPS URL in about 3 minutes. Here's the exact process:
You should see: {"status":"ok","service":"panalo-ai-backend",...}
Stripe setup
Create your pricing plans
Set up the webhook
stripe listen --forward-to localhost:3001/api/webhooks/stripe
This gives you a local webhook secret for dev testing.
Connect the frontend HTML apps
The HTML apps you've built need to point to your live backend URL. Open each HTML file and update the API base URL:
// At the top of your <script> block in each HTML file:
const API_BASE = 'https://YOUR-RAILWAY-URL.up.railway.app/api';
// Replace all direct data with real API calls. Example:
async function loadTasks() {
const token = localStorage.getItem('panalo_token');
const res = await fetch(`${API_BASE}/tasks`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const { tasks } = await res.json();
renderTaskList(tasks);
}
// Auth example:
async function doLogin(email, password) {
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const { accessToken, refreshToken, user } = await res.json();
localStorage.setItem('panalo_token', accessToken);
localStorage.setItem('panalo_refresh', refreshToken);
return user;
}
2. Put your HTML files in a folder, then run: vercel --prod
3. Set FRONTEND_URL in Railway to your new Vercel URL
4. Add your Vercel URL to ALLOWED_ORIGINS in Railway
Live testing — step by step
Once deployed, test these endpoints in order to verify everything works end to end.
1. Health check
2. Register a test user
curl -X POST https://YOUR-URL.up.railway.app/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"email": "test@yourcompany.com",
"password": "TestPass123!",
"first_name": "Test",
"last_name": "User",
"plan": "pro"
}'
Save the accessToken from the response — you'll use it for all other requests.
3. Submit a task (with real AI)
curl -X POST https://YOUR-URL.up.railway.app/api/tasks \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-d '{
"title": "Research top 3 CRM tools for a 20-person sales team",
"description": "Need pricing, key features, and pros/cons for HubSpot, Salesforce, and Pipedrive",
"priority": "normal"
}'
Claude Sonnet 4.6 will attempt this task. If confidence ≥70%, it auto-completes. If not, it escalates to your agent queue.
4. Check task status
curl https://YOUR-URL.up.railway.app/api/tasks/TASK_ID \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
5. Test with Postman (recommended)
Download Postman for a visual way to test all endpoints. Import the collection by pointing at your base URL.
API routes reference
Auth — /api/auth
| Method | Path | Description | Auth |
|---|---|---|---|
| POST | /api/auth/register | Create account, get tokens | — |
| POST | /api/auth/login | Login, get tokens | — |
| POST | /api/auth/refresh | Refresh access token | — |
| POST | /api/auth/logout | Invalidate tokens | JWT |
| GET | /api/auth/me | Get current user profile | JWT |
| POST | /api/auth/forgot-password | Send reset email | — |
| POST | /api/auth/reset-password | Reset with token | — |
Tasks — /api/tasks
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /api/tasks | List tasks (paginated, filterable) | JWT |
| GET | /api/tasks/:id | Get task detail + AI result + messages | JWT |
| POST | /api/tasks | Submit task → AI processes it | JWT |
| PATCH | /api/tasks/:id | Update status/priority | JWT |
| DELETE | /api/tasks/:id | Cancel a pending task | JWT |
| POST | /api/tasks/preview | AI preview for submit UI | JWT |
| GET | /api/tasks/stats/summary | Dashboard stats | JWT |
Messages — /api/messages
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /api/messages/:taskId | Get all messages for a task | JWT |
| POST | /api/messages/:taskId | Send message (triggers email) | JWT |
| GET | /api/messages/inbox/all | All tasks with message threads | JWT |
Billing — /api/billing
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /api/billing/plans | All plan details and pricing | — |
| GET | /api/billing/subscription | Current plan + usage | JWT |
| POST | /api/billing/checkout | Create Stripe checkout session | JWT |
| POST | /api/billing/portal | Stripe billing portal URL | JWT |
| GET | /api/billing/invoices | Invoice history from Stripe | JWT |
Troubleshooting
Server won't start
Check that all required .env variables are set. The most common cause is a missing JWT_SECRET or SUPABASE credentials. Run node src/index.js directly to see the raw error.
Database errors on task creation
Make sure you ran the SQL schema in Supabase first. Go to Supabase → Table Editor — you should see users, tasks, messages, agents, task_results tables.
AI not responding
Check your ANTHROPIC_API_KEY is correct and has credit. Test it directly: curl https://api.anthropic.com/v1/messages -H "x-api-key: YOUR_KEY" -H "anthropic-version: 2023-06-01" -H "content-type: application/json" -d '{"model":"claude-haiku-20240307","max_tokens":10,"messages":[{"role":"user","content":"Hi"}]}'
CORS errors in browser
Add your frontend URL to ALLOWED_ORIGINS in Railway. Example: http://localhost:3000,https://panalo.ai
Stripe webhook not firing
Make sure the webhook URL matches exactly: https://YOUR-URL/api/webhooks/stripe. Check Railway logs for the "Stripe webhook received" log line. In test mode, use the Stripe CLI to forward events locally.
View Railway logs
# Install Railway CLI npm install -g @railway/cli # Login and view logs railway login railway logs
Railway docs: docs.railway.app
Supabase docs: supabase.com/docs
Anthropic docs: docs.anthropic.com