We built a native iOS marketplace on SwiftUI, Supabase and Stripe Connect: 212 Swift files, 17 migrations, 12 edge functions including a full Stripe Connect onboarding and payout flow — and an Xcode build phase that fails the archive if any of ten API-key patterns appears in the source.
A marketplace app has to move money between two parties who are both users, which means it is not a payments integration — it is a payments platform. The operator has to onboard sellers through identity verification, hold funds, split them, handle refunds, produce tax reporting, and deal with a seller whose account goes into review halfway through a transaction. Stripe Connect exists precisely because this is hard, and using it correctly is still a substantial build. Layered on top is the mobile release problem, which is that iOS gives you one shot per review cycle. A rejected build is a week. A build that ships with a hardcoded key is worse than a week — it is a credential in a binary that is now on tens of thousands of devices, and you cannot recall it, you can only rotate the key and hope. This project's most transferable idea is its answer to that second problem, and it is aggressively simple.
`Scripts/audit_secrets.sh` runs as an Xcode build phase named 'Audit Secrets', wired into the project so it executes on every build. Its stated purpose, in its own header, is to fail the build if any API-key-shaped literal appears in Swift sources, and on Release archives to additionally fail if the xcconfig still contains unfilled `REPLACE_ME` placeholders. It matches ten patterns, and the list is specific to the stack rather than generic: Stripe live and test secret keys (`sk_live_`, `sk_test_`), Supabase publishable and secret keys (`sb_publishable_`, `sb_secret_`), any literal `SUPABASE_SERVICE_ROLE`, OpenAI-style `sk-` keys, Google `AIza` keys, Groq `gsk_` keys, xAI `xai-` keys, and Brevo `xkeysib-` keys. It runs with `set -euo pipefail` and resolves its source root from `$SRCROOT` with a script-relative fallback so it also works from the command line. The two properties that make this good rather than merely present: it is a build phase rather than a CI job, so it fails on the developer's machine in the seconds after they write the mistake rather than fifteen minutes later in a pipeline; and the Release-only placeholder check means an archive cannot be produced from a half-configured xcconfig, which is the actual mechanism by which most 'we shipped with the wrong environment' incidents happen. We have since carried this pattern into other projects. It costs an afternoon and it removes an entire class of incident.
The Supabase edge functions split the payment surface along its real seams: `stripe-connect-onboard` starts the seller's account link, `stripe-connect-status` reports where that account is in verification, and `stripe-connect-webhook` receives platform events. `stripe-webhook` handles the ordinary payment events separately, and `stripe-delete-customer` handles teardown. The status function is the one that distinguishes a real Connect integration from a tutorial one. Connect onboarding is not a single event — a seller can be `charges_enabled` but not `payouts_enabled`, can be restricted pending a document, or can be moved back into review after a change. An app that treats onboarding as a boolean will confidently show a seller a payout screen that Stripe will reject. Having a dedicated status endpoint, plus a `ConnectStatusBadge` in the UI, means the app renders the truth rather than an assumption. RevenueCat sits alongside for subscriptions with its own webhook, and the deletion surface is unusually complete for a project this size: `account-delete`, `data-export`, `storage-wipe-user`, `stripe-delete-customer` and `revenuecat-delete-subscriber` are five separate functions. That is what an actually-implemented right-to-erasure looks like — a user deletion has to reach the auth record, the database rows, the object storage, the payment processor and the subscription vendor, and each of those is a different API with different failure modes.
Seventeen Supabase migrations carry the schema. The interesting ones are the ones that are explicitly about security and are numbered as first-class schema changes rather than added later as configuration: `0013_audit_log_phi.sql`, `0099_rls_audit.sql`, `0100_storage_rls.sql`. Storage RLS in particular is the thing most Supabase projects forget — bucket policies are a separate access-control system from table policies, and a project with immaculate table RLS can still be serving every user's uploads from a public bucket. `AuditLogService.swift` on the client side pairs with the PHI audit log migration. The later migrations (`0101_coaching_features`, `0102_enterprise_gym_hierarchy`) show the product growing from a single-operator marketplace into multi-tenant hierarchy, which is the schema change that breaks every assumption an early-stage app made about ownership. That enterprise hierarchy migration is the interesting one architecturally. A marketplace that starts as 'a coach and their clients' encodes ownership as a single foreign key, and every RLS policy, every query and every screen quietly depends on that shape. Introducing a gym that owns locations that employ coaches who have clients means ownership becomes a path rather than a pointer, and every one of those policies has to be revisited. Doing it as a numbered migration with the storage and RLS audit migrations already in place is the difference between a schema change and a rewrite. The client mirrors the backend's seams: `StripeConnectOnboardingView`, `ConnectStatusBadge`, `RevenueDashboardView`, `TaxReportsView`, `BusinessOSView` and `DeleteAccountService` are all separate surfaces, which means the payments state machine is visible in the view layer rather than hidden inside a single settings screen. 212 Swift files across a marketplace, a booking flow, a revenue dashboard and tax reporting is a large app by indie standards, and the file-per-concern discipline is what keeps it navigable.
Marketplace payments are hard in a way that single-party payments are not, because the platform is legally and operationally in the middle. Every state a Connect account can be in has to have a corresponding state in your app and a corresponding screen, and there are more of them than anyone expects: pending verification, restricted, restricted-soon, charges-enabled-but-not-payouts-enabled, disabled, and rejected. Most integrations model two states and then discover the rest in production, one confused seller at a time. Right-to-erasure is much harder than it looks once a user's data is spread across an auth provider, a database, an object store, a payment processor and a subscription vendor. Deletion has to be idempotent, partially resumable, and ordered — you cannot delete the database row that tells you which Stripe customer to delete before you delete the Stripe customer. Five separate functions is not over-engineering; it is the minimum number of independently retryable steps. And the constraint that shapes everything else about mobile work: the release loop is measured in days, not minutes. There is no hotfix. That asymmetry is why the secret audit belongs in a build phase rather than in CI, why the placeholder check is Release-gated, and why the value of a pre-submission check is not the bugs it catches but the review cycles it does not spend.
Built with Swift / SwiftUI, Supabase (Postgres + RLS + Edge Functions), Stripe Connect, RevenueCat, Xcode build phases and xcconfig. Relationship: Internal build. Stage: Built, not launched. Period: 2026. Our role: iOS app, payments architecture, backend, release hardening Figures re-derived on 2026-08-23.