SaaS Self-Serve Onboarding — Public Signup to Tenant Login
Audiences: developer, internal, investor
A prospective clinic can sign up for Dudoxx HMS on a public web form, pay through Stripe Checkout, and have a fully provisioned, isolated tenant with a working admin login — with no human in the loop. This is the orchestration layer that turns a marketing visitor into a paying, provisioned tenant; it sits above the low-level Tenant Provisioner and the SaaS Billing Stripe integration.
Business Purpose
Self-serve onboarding removes the sales-and-setup bottleneck from clinic acquisition. Instead of a manual super-admin provisioning a tenant after a signed contract, a clinic owner completes a single form, pays, and is dropped into their own subdomain — typically in under a minute. This is the difference between a high-touch enterprise sales motion and a product-led-growth motion that scales without headcount.
The flow is run by ddx-saas (the SaaS control plane, port 6300), a NestJS service distinct from the main clinical API (ddx-api, port 6100). ddx-saas owns signup intake, Stripe billing, and a durable provisioning state machine; it calls ddx-api over a trusted service-to-service gateway to do the actual tenant + user creation. The split keeps the public, internet-facing signup surface isolated from the clinical data plane.
Business outcome: A clinic owner visits https://app.dudoxx.com/public/signup/saas?tier=PRO, fills name + email + password + phone + country, accepts terms, and pays with a card. Within ~60 seconds a <clinic-slug>.dudoxx.com tenant exists with their admin account, and they are redirected to log in. Every GDPR consent, IP, and user-agent is captured for the audit trail.
Audiences
- Investor: Product-led, self-serve tenant creation is the unit-economics lever for a SaaS healthcare platform — customer acquisition cost drops because provisioning is fully automated and gated on a confirmed Stripe payment. The flow captures GDPR consent with timestamps, supporting EU clinic procurement.
- Clinical buyer (clinic owner / CMO): Sign up and pay online; your clinic is ready in about a minute with your admin account, your branding colours, and an isolated data environment. No IT project, no onboarding call required.
- Developer/partner: The flow is a Stripe-webhook-driven state machine in ddx-saas. Signup intake (
POST /saas/signup/start) creates aProvisioningJob; thecheckout.session.completedwebhook enqueues provisioning; a 9-step pipeline calls ddx-api super-admin endpoints. All ddx-api calls useX-API-Key: DDX_API_GATEWAY_KEY(= ddx-api'sGATEWAY_API_KEY_SAAS) +X-Clinic-ID: platform. - Internal (ops/support): Job status is pollable at
GET /saas/signup/status/:jobId. A tenant that has been charged by Stripe is never rolled back; if a post-charge email fails, the job parks atREADY_PENDING_VERIFICATIONand a resend endpoint exists. Provisioning jobs resume from the first non-COMPLETED step on ddx-saas restart.
Architecture
The flow spans three services with a strict contract-first boundary:
Browser (ddx-web) ddx-saas (control plane) ddx-api (clinical plane)
───────────────── ──────────────────────── ────────────────────────
SignupForm.tsx POST /saas/signup/start
password, phone, country, ──▶ validate Turnstile + Zod
timezone, marketing, create ProvisioningJob (GDPR payload)
acceptTermsAt send signup_received email ─────▶ POST /super-admin/notifications/transactional
│ return Stripe Checkout URL
▼
Stripe Checkout (card 4242 in dev)
│ webhook: checkout.session.completed
▼
webhook.service → enqueue job
provisioning.service (9 steps):
1 reserve-slug
2 create-dns-cname ─────▶ (prod: ddx-dns-api; dev: no-op)
3 regen-dev-cert (dev mkcert SAN refresh)
4 create-organization ─────▶ POST /super-admin/tenants (org + FHIR partition + MinIO buckets)
5 apply-branding ─────▶ POST /super-admin/tenants/:orgId/branding
6 create-admin-invitation ─────▶ POST /super-admin/users (CLINIC_ADMIN, password, emailVerified+phoneVerified)
7 send-welcome-email ─────▶ POST /super-admin/notifications/transactional (clinic_ready)
8 write-license-snapshot
9 mark-active → READY
◀── poll GET /saas/signup/status/:jobId ──┘
▼
Redirect to https://<slug>.dudoxx.com/auth/login → admin logs in
The provisioning pipeline runs only after the Stripe payment webhook, so reaching step 4 already implies a confirmed charge. Each step runs outside the Prisma transaction (steps perform external HTTP/SMTP I/O that exceeds the 5s interactive-transaction timeout); only the per-step phase/payload DB write is transactional. Steps are idempotent, so resume-after-restart is safe.
Tech Stack & Choices
- ddx-saas: NestJS 11, its own Postgres database (
ddx_api_saas), Redis queue (tenant.provision), Stripe SDK, Cloudflare Turnstile (bot protection on the public form). - Contract-first across services: ddx-api defines the DTO/endpoint; ddx-saas's
DdxApiClientconsumes it and unwraps the{data, meta}response envelope. Auth is the Trusted Gateway (X-API-Key=GATEWAY_API_KEY_SAAS, resolved to aSUPER_ADMINservice account;X-Clinic-ID: platformbypasses tenant isolation for super-admin routes). - Reuse over invention: admin user creation reuses the existing
POST /super-admin/usersrather than a bespoke invitations endpoint; tenant creation reusesPOST /super-admin/tenants. - Verify-on-pay (current policy): paid admins are created with
emailVerified+phoneVerifiedalready set — Stripe payment is treated as sufficient identity proof, so no verification email is sent and the login-gate is skipped for the paid path. The verification machinery (the email-verification gate, theREADY_PENDING_VERIFICATIONphase, the resend endpoint) remains in the codebase, dormant, for any future non-paid or invite-based path. - GDPR:
acceptTermsAtis stored as a timestamp (not a boolean), alongsidemarketingOptIn(a separate, distinct consent — never derived from terms),ipAddress, anduserAgentcaptured server-side from request headers (tamper-safe), all persisted toProvisioningJob.payload.
Data Flow
- Intake —
SignupForm.tsx(a'use client'form) collects clinic name, owner name/email, password (min 12 + complexity, strength meter), phone (E.164 + country dial picker), country, timezone (auto-detected viaIntl), marketing opt-in, optional company size + use case, and a Turnstile token. A dev-only faker filler (SignupDevFiller.tsx, gated onNODE_ENV === 'development') populates valid test data. - Server action (
actions.ts) forwards all fields to ddx-saas through the Next.js BFF (the browser never calls ddx-saas or ddx-api directly), addingipAddress+userAgentfrom request headers. POST /saas/signup/startvalidates Turnstile + the Zod schema, creates aProvisioningJob(statusPENDING) with the GDPR-bearing payload, fires thesignup_receivedemail, and returns a Stripe Checkout session URL withmetadata.{jobId, slug, planTier, orgEmail}.- Stripe Checkout completes; Stripe POSTs
checkout.session.completedto/saas/billing/webhook.webhook.servicelooks up the job bysession.metadata.jobIdand enqueues provisioning. - Provisioning pipeline runs the 9 steps; each completed step is recorded so the job can resume after a restart. On success the job reaches
READYwith aloginUrl. - Redirect + login — the status page polls
GET /saas/signup/status/:jobId; onREADYit redirects to the tenant subdomain login. Because the admin is pre-verified (verify-on-pay), login succeeds immediately.
Implicated Code
ddx-saas/src/signup/signup.service.ts— intake, GDPR payload assembly,signup_receivedemail.ddx-saas/src/signup/dto/start-signup.dto.ts— Zod schema (password/phone/country/timezone/marketing/acceptTermsAt).ddx-saas/src/billing/{stripe,webhook}.service.ts— Checkout session (with metadata) + webhook → job lookup + enqueue.ddx-saas/src/provisioning/provisioning.service.ts— the 9-step state machine; per-step transaction boundary; resume + charge-boundary logic;READY_PENDING_VERIFICATIONparking.ddx-saas/src/provisioning/steps/*.step.ts— reserve-slug, create-dns-cname, regen-dev-cert, create-organization, apply-branding, create-admin-invitation, send-welcome-email, write-license-snapshot, mark-active.ddx-saas/src/platform/ddx-api/ddx-api-client.ts— typed client;createTenant,applyBranding,createAdminUser,sendTransactional; envelope unwrap.ddx-api/src/organization/super-admin/controllers/admin-orgs.controller.ts—POST /super-admin/tenants/:orgId/branding.ddx-api/src/organization/super-admin/controllers/admin-notifications.controller.ts—POST /super-admin/notifications/transactional(5 templates + audit).ddx-api/src/organization/super-admin/services/practitioner-management.service.ts— admin user creation with optionalemailVerified/phoneVerified(verify-on-pay) +onboardingData.sourceprovenance.ddx-api/src/platform/auth/services/authentication.service.ts— the email-verification login gate (dormant on the paid path; scoped ononboardingData.source === 'self-serve-saas').ddx-web/src/app/[locale]/public/signup/saas/—SignupForm.tsx,SignupDevFiller.tsx,actions.ts; status page undersignup/status/.
Operational Notes
- Dev vs prod: dev uses Cloudflare Turnstile always-pass test keys, mkcert wildcard TLS for
*.localhosttenant subdomains (refreshed per-tenant by theregen-dev-certstep),DDX_DNS_API_TOKENempty (DNS step no-ops), andOVERRIDE_EMAIL_DESTto capture all mail to one inbox. Prod uses real Turnstile, wildcard TLS on the home-edge Apache, realddx-dns-apiCNAMEs, and live SMTP. - Driving a signup without a browser (dev):
POST /saas/signup/startthenstripe trigger checkout.session.completed --override checkout_session:metadata.jobId=<id> --override checkout_session:metadata.slug=<slug> --override checkout_session:metadata.planTier=PRO --override checkout_session:metadata.orgEmail=<email>. - MinIO buckets are created private; tenant isolation is application-layer (ddx-api holds root MinIO credentials and serves files via short-lived presigned URLs after the RBAC + tenant check; bucket names are
{slug}-{type}). No per-bucket policy is applied to private buckets — MinIO rejects the AWS-onlyaws:PrincipalTagcondition key, and the buckets default to owner-only anyway. The branding bucket is the sole exception (public-read, so logos render in<img>tags). - Charge boundary (critical invariant): a Stripe-charged tenant is never deleted. Pre-charge step failures (slug/org/user creation) roll back; post-charge email failures park the job at
READY_PENDING_VERIFICATIONwith a resend affordance. - Process hygiene: ddx-saas runs
nest start --watchin dev. Stale watcher/dist/maininstances accumulating across restarts can serve old code on port 6300; confirm a single listener (lsof -i :6300 -sTCP:LISTEN | wc -l == 1) before trusting an end-to-end run.
Related Topics
- Tenant Provisioner — New Clinic Onboarding — the ddx-api
POST /super-admin/tenantsorg + FHIR partition + MinIO bucket creation that this flow's step 4 calls. - SaaS Billing — Stripe Subscription Management — the Stripe plans, checkout, and subscription lifecycle.
- Organization Management — post-provision clinic configuration.
- Tenant Isolation — the interceptor-based scoping every provisioned tenant relies on.
- User Onboarding — registration + initial setup flows for users within a clinic.