11-registration-and-onboardingwave: W6filled0 citations

SaaS Self-Serve Onboarding — Public Signup to Tenant Login

Audiences: developer, internal, investor

SaaS Self-Serve Onboarding — Public Signup to Tenant Login

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 a ProvisioningJob; the checkout.session.completed webhook enqueues provisioning; a 9-step pipeline calls ddx-api super-admin endpoints. All ddx-api calls use X-API-Key: DDX_API_GATEWAY_KEY (= ddx-api's GATEWAY_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 at READY_PENDING_VERIFICATION and 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:

Rendering diagram…

POST /saas/signup/start — validate Turnstile + Zod · create ProvisioningJob (GDPR payload) · send signup_received email → POST /super-admin/notifications/transactional · return Stripe Checkout URL.

provisioning.service — the 9 steps

#StepCalls out to
1reserve-slug
2create-dns-cnameprod: ddx-dns-api; dev: no-op
3regen-dev-certdev mkcert SAN refresh
4create-organizationPOST /super-admin/tenants (org + FHIR partition + MinIO buckets)
5apply-brandingPOST /super-admin/tenants/:orgId/branding
6create-admin-invitationPOST /super-admin/users (CLINIC_ADMIN, password, emailVerified+phoneVerified)
7send-welcome-emailPOST /super-admin/notifications/transactional (clinic_ready)
8write-license-snapshot
9mark-activeREADY

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 DdxApiClient consumes it and unwraps the {data, meta} response envelope. Auth is the Trusted Gateway (X-API-Key = GATEWAY_API_KEY_SAAS, resolved to a SUPER_ADMIN service account; X-Clinic-ID: platform bypasses tenant isolation for super-admin routes).
  • Reuse over invention: admin user creation reuses the existing POST /super-admin/users rather than a bespoke invitations endpoint; tenant creation reuses POST /super-admin/tenants.
  • Verify-on-pay (current policy): paid admins are created with emailVerified + phoneVerified already 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, the READY_PENDING_VERIFICATION phase, the resend endpoint) remains in the codebase, dormant, for any future non-paid or invite-based path.
  • GDPR: acceptTermsAt is stored as a timestamp (not a boolean), alongside marketingOptIn (a separate, distinct consent — never derived from terms), ipAddress, and userAgent captured server-side from request headers (tamper-safe), all persisted to ProvisioningJob.payload.

Data Flow

  1. IntakeSignupForm.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 via Intl), marketing opt-in, optional company size + use case, and a Turnstile token. A dev-only faker filler (SignupDevFiller.tsx, gated on NODE_ENV === 'development') populates valid test data.
  2. 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), adding ipAddress + userAgent from request headers.
  3. POST /saas/signup/start validates Turnstile + the Zod schema, creates a ProvisioningJob (status PENDING) with the GDPR-bearing payload, fires the signup_received email, and returns a Stripe Checkout session URL with metadata.{jobId, slug, planTier, orgEmail}.
  4. Stripe Checkout completes; Stripe POSTs checkout.session.completed to /saas/billing/webhook. webhook.service looks up the job by session.metadata.jobId and enqueues provisioning.
  5. Provisioning pipeline runs the 9 steps; each completed step is recorded so the job can resume after a restart. On success the job reaches READY with a loginUrl.
  6. Redirect + login — the status page polls GET /saas/signup/status/:jobId; on READY it 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_received email.
  • 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_VERIFICATION parking.
  • 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.tsPOST /super-admin/tenants/:orgId/branding.
  • ddx-api/src/organization/super-admin/controllers/admin-notifications.controller.tsPOST /super-admin/notifications/transactional (5 templates + audit).
  • ddx-api/src/organization/super-admin/services/practitioner-management.service.ts — admin user creation with optional emailVerified/phoneVerified (verify-on-pay) + onboardingData.source provenance.
  • ddx-api/src/platform/auth/services/authentication.service.ts — the email-verification login gate (dormant on the paid path; scoped on onboardingData.source === 'self-serve-saas').
  • ddx-web/src/app/[locale]/public/signup/saas/SignupForm.tsx, SignupDevFiller.tsx, actions.ts; status page under signup/status/.

Operational Notes

  • Dev vs prod: dev uses Cloudflare Turnstile always-pass test keys, mkcert wildcard TLS for *.localhost tenant subdomains (refreshed per-tenant by the regen-dev-cert step), DDX_DNS_API_TOKEN empty (DNS step no-ops), and OVERRIDE_EMAIL_DEST to capture all mail to one inbox. Prod uses real Turnstile, wildcard TLS on the home-edge Apache, real ddx-dns-api CNAMEs, and live SMTP.
  • Driving a signup without a browser (dev): POST /saas/signup/start then stripe 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-only aws:PrincipalTag condition 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_VERIFICATION with a resend affordance.
  • Process hygiene: ddx-saas runs nest start --watch in dev. Stale watcher/dist/main instances 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.
    SaaS Self-Serve Onboarding — Public Signup to Tenant Login — Dudoxx Docs | Dudoxx