The backend for Orbit -- an AI-powered habit tracker. Provides a REST API for habit management, an AI agent (chat + MCP tools), gamification, goals, calendar sync, social features, push notifications, and subscription billing. Built with Clean Architecture and CQRS.
Live: api.useorbit.org | App: app.useorbit.org | Landing: useorbit.org
| Layer | Technology |
|---|---|
| Runtime | .NET 10.0, C# 13 |
| Database | PostgreSQL via EF Core 10 (Npgsql) |
| CQRS | MediatR 14 |
| Validation | FluentValidation 12 |
| Auth | JWT Bearer + email verification codes + Google OAuth authorization code exchange |
| AI | OpenAI via the Responses API and OpenAI .NET SDK 2.14.0; gpt-6-luna for chat and sub-tasks at low reasoning |
| Amazon SES (verification codes, contacts) | |
| Push | Firebase Admin SDK (FCM, native Android) + WebPush (VAPID, browsers) |
| Payments | Stripe (web) + Google Play Billing (native Android) |
| Storage | Private Amazon S3 uploads bucket |
| Password/Token Hashing | BCrypt |
| Observability | Sentry |
| API Docs | Scalar (dev only) |
| Testing | xUnit + FluentAssertions |
| Containerization | Docker (multi-stage build) |
- Habit CRUD -- Create, update, delete, duplicate, reorder habits with sub-habit support
- Smart Scheduling -- Server-side frequency calculations (daily, weekly, monthly, yearly, every N days) with day-of-week filtering and overdue detection
- Completion Logging -- Toggle completion per day with optional notes, full log history
- Metrics -- Current/longest streaks, weekly/monthly completion rates
- AI Agent -- Multi-turn OpenAI-backed chat that creates habits, logs completions, analyzes progress, suggests breakdowns, assigns tags, and reschedules. Exposed to external clients as MCP tools with per-tool ownership/policy scoping and audit logging. Supports image upload
- AI Assists -- Cached daily summaries, retrospectives, fact extraction, goal review, habit/tag/reschedule suggestions, proactive check-ins, slip alerts, and batched usage summaries
- Gamification -- XP, levels, streaks, streak freezes, and achievements
- Goals -- Goal tracking with deadlines and AI-assisted review
- Calendar -- Google Calendar auto-sync of scheduled habits
- Social -- Friends, public profiles, and referrals
- Checklist Templates -- Reusable habit/checklist templates
- Tags -- Colored tags for habit organization
- Push Notifications -- Dual delivery: FCM for native Android, VAPID Web Push for browsers, plus background schedulers for reminders, goal deadlines, slip alerts, and check-ins
- Email Verification -- Passwordless code-based login via Amazon SES
- Subscription Billing -- Stripe (web) and Google Play Billing (native) with webhook / RTDN processing; backend is the source of truth for entitlements
- Waitlist -- Signed-token waitlist confirmation flow
- Sync -- Batched pull/mutation sync endpoints for clients
- Timezone Support -- All user-facing dates use timezone-aware "today" based on user profile
- Pagination -- List endpoints paginated with
PaginatedResponse<T>
- .NET 10.0 SDK
- PostgreSQL (or Supabase for hosted)
- Docker (optional, for containerized deployment)
# Clone the repository
git clone https://github.com/thomasluizon/orbit-api.git
cd orbit-api
# Copy environment config
cp .env.example .env
# Edit .env / appsettings.Development.json with your database credentials and API keys
# Apply database migrations
dotnet ef database update --project src/Orbit.Infrastructure --startup-project src/Orbit.Api
# Run the API
dotnet run --project src/Orbit.ApiThe API runs at http://localhost:5000. API docs available at /scalar in development.
Settings are bound from appsettings.json, with secrets overridden in appsettings.Development.json locally and environment variables in production. Key sections:
| Section | Purpose |
|---|---|
ConnectionStrings:DefaultConnection / SessionConnection |
PostgreSQL (direct + session pooler) |
AI:ApiKey / AI:Model / AI:SubTaskModel / AI:BaseUrl |
OpenAI credentials and models (BaseUrl defaults to https://api.openai.com/v1) |
Jwt:SecretKey / Issuer / Audience |
JWT signing and claims |
Storage:S3:Bucket / Region / PublicBaseUrl / AccessKeyId / SecretAccessKey |
Required S3 upload, read, and erasure configuration |
Ses:AccessKeyId / SecretAccessKey / FromEmail |
Transactional email |
Vapid:PublicKey / PrivateKey / Subject |
Web Push (VAPID) |
Encryption:Key |
At-rest field encryption |
Google:ClientId / ClientSecret |
Google OAuth + Calendar |
Stripe:SecretKey / WebhookSecret / price IDs |
Stripe billing |
Cors:AllowedOrigins / LandingOrigins |
Allowed frontend origins |
Sentry:Dsn / Environment |
Error monitoring |
Clean Architecture with four layers:
src/
Orbit.Api/ # Presentation layer
Controllers/ # Controllers (see below)
Extensions/ # PayGate-aware IActionResult helpers
Middleware/ # Security headers, unhandled/validation exception handlers
Program.cs # DI configuration, middleware pipeline
Orbit.Application/ # Application layer (CQRS)
Common/ # PaginatedResponse<T>, ErrorMessages, AppConstants, PayGateService, ...
Habits/ # Commands / Queries / Validators / Services (HabitScheduleService)
Chat/ # AI chat orchestration
Goals/ Gamification/ # Goals, XP/streaks/achievements
Calendar/ # Google Calendar sync
Referrals/ # Referral rewards
ChecklistTemplates/ Tags/ Uploads/ # Content
Auth/ Profile/ ApiKeys/ Subscriptions/ Support/ Notifications/ Waitlist/
Behaviors/ # MediatR pipeline (ValidationBehavior, ...)
Orbit.Domain/ # Domain layer (zero dependencies)
Entities/ Enums/ Interfaces/ Common/ Models/ # Entities, Result<T>, repository/service contracts
Orbit.Infrastructure/ # Infrastructure layer
Persistence/ # OrbitDbContext, GenericRepository<T>, UnitOfWork
Services/ # AI (AiIntentService, AiSummaryService, ...),
# Agent/MCP (AgentCatalogService, AgentOperationExecutor, AgentPolicyEvaluator, ...),
# billing (StripeBillingService, GooglePlayBillingService),
# push + schedulers (PushNotificationService, ReminderSchedulerService, ...),
# auth (JwtTokenService, GoogleTokenService), UserDateService, EncryptionService
Migrations/ # EF Core migrations
tests/
Orbit.Domain.Tests/ # xUnit + FluentAssertions
Orbit.Application.Tests/ # xUnit + FluentAssertions
Orbit.Infrastructure.Tests/ # xUnit + FluentAssertions
Achievements, Ai, ApiKeys, Auth, Calendar, Chat, ChecklistTemplates, Config, Gamification, Goals, Habits, Notification, OAuth, Profile, Referral, Subscription, Support, Sync, Tags, Uploads, Waitlist.
- Result<T> -- Handlers return
Result<T>instead of throwing for expected failures - CQRS -- Command/query separation via MediatR, one folder per feature
- Factory methods -- Entities created via
Entity.Create()static methods - Generic repository + Unit of Work -- Abstracted data access
- PayGate -- Subscription-gated features via
PayGateServiceandResultpropagation helpers - Validation pipeline --
ValidationBehavior<TRequest, TResponse>in the MediatR pipeline - Cache invalidation -- AI summary cache cleared on any habit mutation
Dual delivery via PushNotificationService:
- FCM -- Firebase Admin SDK for native Android. Subscriptions with
p256dh == "fcm"route through Firebase - Web Push -- VAPID-based for browsers, via
Lib.Net.Http.WebPush - Schedulers -- Background services (
ReminderSchedulerService,GoalDeadlineNotificationService,SlipAlertSchedulerService,ProactiveCheckinSchedulerService) send push + create in-app notifications; aSentReminderrecord prevents duplicates
# Run all unit tests
dotnet testTests use xUnit with FluentAssertions. Unit tests only — there is no integration or E2E suite.
Habit ranking uses an API-first rollout with client capability negotiation. See the consumer contract and activation runbook.
| Component | Service |
|---|---|
| Hosting | Render (Docker, manual API releases) |
| Database | Supabase PostgreSQL (session pooler) |
| Domain | api.useorbit.org |
| Push | Firebase project orbit-11d4a (FCM) |
| Amazon SES | |
| Payments | Stripe + Google Play Billing |
| Monitoring | Sentry |
Run release.yml from main with an environment of production or staging and a branch to release. Production accepts only main; staging deploys the selected branch head. Both jobs use the Render API to wait for the requested deploy, then poll public /health until it reports Healthy and the exact selected commit. Public verification has one 180-second budget, waits up to five seconds between probes, and caps each request at ten seconds or the remaining budget. Stale commits, unhealthy or invalid responses, and HTTP or network failures retry within that budget. The first match ends polling; the final Render live-commit check must also pass before recording the deployment. On exhaustion, the job reports the expected SHA, last valid status and commit, probe count, elapsed time, and a sanitized result category without printing response bodies or credentials. Inspect those identifiers and the Render deploy logs before explicitly rerunning a failed release; verification never rolls back or redeploys automatically.
Set RENDER_API_KEY as an environment secret in each of the production and staging GitHub environments, and set the repository variables RENDER_PRODUCTION_SERVICE_ID and RENDER_STAGING_SERVICE_ID. Restrict the production, staging, and render-operations GitHub environments to deployment branch main. The staging reseed and Postgres access reconciliation workflows use the render-operations environment secret RENDER_API_KEY for database and service management. Remove the repository secret RENDER_API_KEY after both release paths and the operational workflows succeed.
Turnstile remains disabled after deployment. Create a Turnstile widget for useorbit.org in the Cloudflare dashboard and set BotProtection__SecretKey in the Render orbit-api environment. Keep BotProtection__Enabled unset or false until all three conditions hold:
- The deployed web sign-in sends
turnstileTokenfor both send-code and verify-code. Check production requests and the deployed web version. CheckAPP_VERSIONin every deployed environment of the Orbit web project in Vercel before raising the version floor. An unsetAPP_VERSIONfails open; a set value must be at least the proposed floor. - The Android build sends
turnstileTokenfor both calls and is live on the Google Play track. Check the Play Console release and active version distribution. Only then raise the productionAppConfigs.MinSupportedVersionrow to that build using a guarded update, and read the row back to verify the exact value. Allow up to 30 minutes for the per-process cache to apply the floor. - The deployed landing waitlist sends
turnstileTokenin the/api/waitlistbody. The source atorbit-landing-page/src/scripts/waitlist.tsalready does this onmain; check the deployed form request and confirmPUBLIC_TURNSTILE_SITE_KEYis set for the landing deployment.
Then set BotProtection__Enabled=true in the Render orbit-api environment. Verify token-bearing web and Android sign-in and the landing waitlist succeed, while tokenless requests to all five protected routes return 400. Monitor HTTP 426 traffic and client reports for 24 hours after the version-floor raise. Existing clients without a token continue to work while the switch is false. The production smoke account bypasses this gate only when both SMOKE_TEST_EMAIL and SMOKE_TEST_CODE are configured, so knowledge of that address permits sends only to Orbit's own mailbox.
# Build and run with Docker Compose
docker compose up -d --buildThe Dockerfile uses a multi-stage build (SDK for build, ASP.NET runtime for production) and runs as a non-root user.
| Repo | Description |
|---|---|
| orbit-ui-mobile | Turborepo frontend — apps/web (Next.js 16) + apps/mobile (Expo, Android) + packages/shared |
| orbit-landing-page | Marketing landing page |
Private project.
The profile data export returns the account's portable data while the account exists.
Erasure removes the account and owned data. Provider cancellation obligations are billing
reconciliation data and survive erasure separately in OwedSubscriptionCancellations.
They cannot be joined back to an account by an Orbit user identifier and are not included
in the profile export, because they are created as the account is erased.
Each record keeps a random record ID, the provider (Stripe or Google Play), the Stripe subscription ID or Play purchase token, the attempt count, creation time, last attempt time, next attempt time, and exhaustion time. It keeps no name, email, Orbit user ID, payment card, or provider error text. Purchase tokens and subscription IDs must be treated as sensitive billing data; alerts use only the record ID, provider and attempt count.
After the seven-day billing deferral, erasure commits each retained provider identifier's cancellation obligation in the same transaction that deletes the account. An error alert reports erasure with unconfirmed renewals. The cancellation worker scans every minute in both scheduler modes, independently of the erased account and any original queued job. It backs off for one minute, five minutes, then one hour between attempts. After 27 attempts it continues attempting cancellation daily. A successful cancellation, an already stopped renewal, or a provider-confirmed missing subscription removes the record immediately. A failed provider does not prevent another provider's obligation from being processed.
Exhausted obligations remain until billing reconciliation confirms renewal has stopped; there is no time-based expiry that could forget an active charge. Exhaustion emits an error alert with the record ID and provider, repeated daily while unresolved. Billing operators must locate that record, verify and stop renewal with the named provider, and remove the record only after confirming cancellation or that the subscription no longer exists. The next attempt time schedules both daily cancellation attempts and exhaustion reminders. Each attempt is committed before provider calls, which run outside database transactions. Interrupted attempts remain due for a retry after backoff. Provider cancellation that succeeds before a database failure is safe to verify again on the next scan.