Wepilo is a multi-tenant backend for travel agencies. Each agency (a tenant) runs its trips, departures, bookings, inquiries and website content from one dashboard, and its public website reads everything through the Wepilo API.
apps/
api/ Hono API on Cloudflare Workers · D1 (SQLite) via Drizzle · R2 for media
dashboard/ React + Vite + shadcn/ui admin app, served by a Worker that proxies /api
storefront/ Hono + JSX Worker that renders an agency's public site from the API (preview and starting point)
- API integration guide for website developers: docs/API.md
- Dashboard guide for agency staff: Using the dashboard below
Requirements: Node 20+, pnpm 11, a Cloudflare account (only for deploying).
pnpm install
# 1. Secrets for the API (never commit this file)
cp apps/api/.dev.vars.example apps/api/.dev.vars
# set JWT_SECRET to a long random string; the rest can stay as placeholders locally
# 2. Create the local D1 database
pnpm --filter @wepilo/api db:migrate:local
# 3. Run both apps (two terminals, or `pnpm dev` for both)
pnpm dev:api # http://localhost:8787
pnpm dev:dashboard # http://localhost:5173 (proxies /api and /media/file to :8787)
pnpm dev:storefront # http://localhost:5176/{workspace-slug} (reads the API at :8787)Open http://localhost:5173/signup to create a workspace. A new workspace is locked behind the paywall until it pays the $1 trial. See Billing for testing that locally.
| Command | What it does |
|---|---|
pnpm typecheck |
Type-check both apps |
pnpm --filter @wepilo/api test |
API tests (Vitest inside the Workers runtime, real D1 migrations, no real email) |
pnpm build |
Build both apps |
pnpm --filter @wepilo/api db:generate |
Generate a migration after editing apps/api/src/db/schema.ts |
pnpm --filter @wepilo/api db:migrate:local |
Apply migrations to the local D1 database |
pnpm --filter @wepilo/api db:migrate:remote |
Apply migrations to the production D1 database |
pnpm --filter @wepilo/api admin:create -- --email you@x.com --name "You" |
Create a platform admin, or reset their password (prompts for it; add --remote for production) |
Secrets live in apps/api/.dev.vars locally and in Wrangler secrets in production (wrangler secret put NAME --env production).
| Secret | Purpose |
|---|---|
JWT_SECRET |
Signs staff and website-user session cookies |
RESEND_API_KEY |
Sends email (password resets, newsletter) |
PLATFORM_EMAIL_FROM, PLATFORM_SUPPORT_EMAIL |
Sender address and support inbox |
DODO_API_KEY, DODO_WEBHOOK_SECRET |
Dodo Payments API key and webhook signing secret (whsec_…) |
DODO_TRIAL_PRODUCT_ID |
One-time $1 product that unlocks the 3-day trial |
DODO_{STARTER,GROWTH,SCALE}_{MONTHLY,ANNUAL}_PRODUCT_ID |
Subscription product per plan and interval (leave out intervals you don't sell) |
Plain variables (ENVIRONMENT, DODO_ENVIRONMENT (test_mode locally, live_mode in production), COOKIE_DOMAIN, API_BASE_URL, DASHBOARD_URL, SITE_URL) are in apps/api/wrangler.jsonc, with production overrides under env.production.
.gitignore excludes .dev.vars*, .env*, keys and local Wrangler state. Only .dev.vars.example is tracked.
apps/storefront is a small server-rendered site that shows an agency's content the way its visitors would see it: home page sections, trips, destinations, journal, pages and an enquiry form. It only reads the same public API a website developer would, so it doubles as a worked example.
- Preview any agency: open
/{workspace-slug}. The dashboard's Preview site button does this. The preview is markednoindex. - One agency on its own domain: set
TENANT_SLUGinapps/storefront/wrangler.jsoncand the site is served from the root. - Needs an active plan: the public API is closed to workspaces on a trial or without a plan, so the preview says the site isn't live yet.
- Settings it follows: homepage sections, menus, announcement, social links and currency all come from the dashboard.
- Deploy with
pnpm deploy:storefront. SetVITE_STOREFRONT_URLfor the dashboard build so the Preview button points at it.
pnpm deploy:api # applies remote D1 migrations, then deploys Worker "wepilo-api-production"
pnpm deploy:dashboard # builds the SPA, then deploys Worker "wepilo-dashboard"
pnpm --filter @wepilo/api admin:create -- --email you@x.com --name "You" --remote # first platform adminUse these scripts rather than pnpm deploy. That is a built-in pnpm command, not a script, so it doesn't deploy anything to Cloudflare.
| Worker | Domain | |
|---|---|---|
| API | wepilo-api-production (--env production) |
wepiloapi.basilpot.com |
| Dashboard | wepilo-dashboard |
wepilo.basilpot.com |
The D1 database (narfu-db) and R2 bucket (narfu-media) keep their original names, because they hold data and can't be renamed.
Secrets live on the Worker. A new Worker name starts with none, so set them once per Worker with wrangler secret put NAME --env production, run from apps/api (see Environment).
The dashboard Worker serves the built SPA and forwards /api/* to the API host, so staff cookies stay first-party on the dashboard domain.
The API Worker also serves a remote MCP server at /mcp (production: https://wepiloapi.basilpot.com/mcp). One connector works in Claude, ChatGPT, Cursor and AI site builders such as Lovable and Base44.
- Sign-in: OAuth 2.1 with dynamic client registration (
@cloudflare/workers-oauth-provider, wrapping the Worker inapps/api/src/index.ts)./oauth/authorizeparks the request in KV and sends the person to the dashboard's/connectpage.- On that page they sign in with their Wepilo login and choose Allow.
- Each connection belongs to one staff login and keeps that role's permissions.
- Tools (
apps/api/src/mcp/):- They call the REST API in-process with a short-lived staff token, so role checks, the paywall, validation and plan limits are the same as in the dashboard.
- Staff tools read everything and make safe changes only: inquiry status, confirm/complete/cancel bookings, add or edit departures, and trips and posts created as hidden drafts.
- Nothing publishes, deletes, emails travellers, or touches billing, staff or API keys.
- Website-builder tools explain how to connect a public site to the API.
- Storage: OAuth grants, tokens and pending requests live in the
OAUTH_KVnamespace (wepilo-oauth, bound inapps/api/wrangler.jsonc). - Managing connections: people see and disconnect their connected apps in Settings → API keys → Connected AI apps.
- Local testing: run both apps, then point MCP Inspector (
npx @modelcontextprotocol/inspector) athttp://localhost:8787/mcp.
/llms.txt serves docs/API.md as plain text for AI coding tools.
Limits are defined once in apps/api/src/lib/plans.ts (PLANS). The Billing page reads them from GET /tenant/billing, and prices come live from Dodo.
| Starter | Growth | Scale | Enterprise | |
|---|---|---|---|---|
| Trips | 45 | Unlimited | Unlimited | Unlimited |
| Posts (excluding trash) | 200 | Unlimited | Unlimited | Unlimited |
| Staff logins | 2 | 10 | 25 | Unlimited |
| Media storage | 5 GB | 25 GB | 100 GB | Unlimited |
| Website API | ✓ | ✓ | ✓ | ✓ |
Going over a limit returns 402 with code: "PLAN_LIMIT", and the dashboard shows a toast with a "See plans" link.
Lifecycle
- Sign up → locked. A new workspace can only see the paywall. Every other API call returns
402 PAYMENT_REQUIRED. - $1 trial. The owner picks a plan and pays a one-time $1 (
DODO_TRIAL_PRODUCT_ID). Thepayment.succeededwebhook unlocks that plan's dashboard for 3 days.- Nothing renews. Card details aren't used again.
- One trial per person, matched by normalised owner email across workspaces.
- The website API stays off during the trial.
- Trial ends → locked. An hourly cron (
0 * * * *) emails the owner once: "your trial has ended, choose your plan". - Subscription. The owner buys Starter, Growth or Scale (monthly or annual) through Dodo checkout. It renews automatically.
subscription.*webhooks keep access in sync.- Plan switches use Dodo's change-plan with proration.
- "Manage billing" opens Dodo's customer portal (cancel, card, invoices).
- Subscription lapses (failed, cancelled or expired). The dashboard locks right away and the owner gets the same email. The website API keeps working for 15 days (
API_GRACE_DAYS), so the agency's site doesn't go down mid-renewal.
Dodo setup
- Products: one one-time product at $1 for the trial, plus one subscription product per plan and interval. Set no trial on the subscription products; checkout also forces
trial_period_days: 0. - Webhook:
https://wepiloapi.basilpot.com/api/v1/billing/webhookwithpayment.succeededand allsubscription.*events. Put its signing secret inDODO_WEBHOOK_SECRET. - Customer portal: set the return URL to the dashboard's
/settings/billing. - Production secrets:
wrangler secret put DODO_API_KEY --env production(and the rest).
Testing locally. Dodo can't reach localhost. Either expose the API with cloudflared tunnel --url http://localhost:8787 and use that URL as a test-mode webhook, or send signed test events yourself (Standard Webhooks: webhook-id, webhook-timestamp, webhook-signature: v1,<base64 HMAC-SHA256 of "id.timestamp.body">). Trigger the expiry cron with wrangler dev --test-scheduled, then curl "localhost:8787/__scheduled?cron=0+*+*+*+*".
Wepilo's own team works at /admin on the dashboard domain. It has its own login and cookie, separate from any agency workspace.
- Create a workspace for an enterprise deal: company, slug, owner name and email, plan (any plan, Enterprise included) and an end date (Enterprise can have none). These workspaces are managed (
billing_mode = MANUAL): no $1 trial or checkout, and the dashboard and website API stay on until the end date. Afterwards the usual expiry email and 15-day API grace period apply. - Hand it over with a one-time setup link (valid 7 days). The admin can email it from Wepilo or copy it and send it themselves. Issuing a new link cancels the old one. When the client uses it, they choose a password and sign in as the owner.
- Open as owner signs the admin's browser into the workspace as its owner for one hour, for example to load trips before the handover. A banner shows while this is active.
- Change the plan, end date, owner email or internal notes, or suspend the workspace (everyone is signed out and the website API stops).
- Every admin action is logged on the workspace page.
If a managed workspace later subscribes through Dodo, the subscription takes over and the workspace becomes self-serve again.
Create the first admin with admin:create (see Useful scripts). After that, admins can add each other under Admins.
- Travellers can't pay online.
POST /bookingscreates a pending booking that staff confirm by hand. - Few transactional emails. Booking, inquiry and support notifications aren't sent yet.
- Seats are only taken when staff confirm a booking, so pending bookings can add up to more than a departure's capacity.
A typical setup for a new agency:
- Business profile (Settings → Business profile). Add your name, logo, description, phone and address. These appear on your site and in emails.
- Destinations and trip categories. Add the countries, regions and cities you operate in, plus categories like Trekking or Day tours. Use Show on homepage to feature them.
- Trips. Choose Multi-day itinerary for structured trips (day-by-day plan, altitude, meals, stays) or Simple page for short experiences. The editor tracks how complete each trip is. Save as a draft, or Publish to make it live on the website.
- Departures. Add dated departures with capacity and price for each trip. Travellers book against a departure.
- Bookings. New bookings from your site arrive as Pending. Confirming one takes its seats from the departure, and cancelling gives them back.
- Inquiries. Contact-form messages land here. Move each through New → In progress → Resolved/Closed.
- Website content. Pages, Posts (blog), Content categories, Media library and Redirects (old URL → new URL).
- About us and marketing. Team members, Departments, Authors, Testimonials, Reviews, Documents (licences and certificates) and Newsletter subscribers.
- Staff logins (Settings). Invite colleagues with a role:
- Owner: everything.
- Admin: everything except billing and API keys.
- Manager: trips, departures, bookings and content.
- Editor: posts, pages and media only.
- API key (Settings → API keys, any paid plan). Generate a key for your website developer and give them docs/API.md.
Press ⌘K / Ctrl K anywhere to jump to a page.