Adding Stripe Payments to an iOS App Without a Backend
I built Fescue to help solo service businesses schedule jobs, manage clients, and send invoices from their phone. But invoices are only useful if you can actually collect payment. I wanted lawn care operators and cleaners to be able to hand their phone to a client and take a credit card payment on the spot — no Square terminal, no separate app.
That meant integrating Stripe. And Stripe means you need some kind of server.
The “no backend” lie
Let’s be honest: you cannot do Stripe payments with zero backend. Stripe’s secret key must never touch the client. You need server-side code to create PaymentIntents, manage customer objects, and handle webhooks. The question isn’t whether you need a backend — it’s how small you can make it.
I started with a serverless prototype. It worked, but I already had a lightweight Go API running for Forecastly. Adding Fescue’s payment routes to that existing service was simpler than maintaining a separate deployment.
The server handles auth, Stripe Connect onboarding, PaymentIntent creation, checkout sessions, and webhooks.
Why Stripe Connect, not plain Stripe
Fescue isn’t charging customers directly. It’s a platform where each service business needs their own Stripe account with payouts going to their own bank. That’s Stripe Connect.
The flow: a Fescue user signs in with Apple, sets up their business profile, and taps “Set Up Payments.” The app calls my API, which creates a Stripe Express account and returns an onboarding URL. The user completes Stripe’s hosted onboarding in Safari. When they return, the app checks their account status via another API call.
Once active, they can take payments. Fescue collects a small application fee on transactions as a secondary revenue stream alongside subscriptions.
The server side
The API is Go with chi for routing and the official stripe-go SDK. Here’s the core of PaymentIntent creation:
func (s *FescueStripeService) CreatePaymentIntent(
amount int64, connectedAccountID string,
feePercent float64, metadata map[string]string,
description string,
) (*PaymentIntentResult, error) {
appFee := int64(math.Round(float64(amount) * feePercent / 100))
params := &stripe.PaymentIntentParams{
Amount: stripe.Int64(amount),
Currency: stripe.String(string(stripe.CurrencyUSD)),
TransferData: &stripe.PaymentIntentTransferDataParams{
Destination: stripe.String(connectedAccountID),
},
AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{
Enabled: stripe.Bool(true),
},
}
if appFee > 0 {
params.ApplicationFeeAmount = stripe.Int64(appFee)
}
pi, err := s.sc.PaymentIntents.New(params)
// ...
}
The TransferData.Destination is the connected account. Stripe automatically routes the payment minus any application fee to the business’s bank account. My platform keeps the fee. That’s the whole business model in five lines.
The iOS side
The app uses StripePaymentSheet — Stripe’s prebuilt UI. No custom card forms, no PCI headaches. The flow is three steps:
- App calls my API with the invoice amount and client email
- API creates a PaymentIntent and returns the
clientSecretplus apublishableKey - App configures and presents the PaymentSheet
let result = try await payment.createPayment(
amount: amountCents,
invoiceId: invoice.invoiceNumber,
clientEmail: email,
clientName: clientName,
description: "Invoice \(invoice.invoiceNumber)"
)
STPAPIClient.shared.publishableKey = result.publishableKey
var config = PaymentSheet.Configuration()
config.merchantDisplayName = businessName
paymentSheet = PaymentSheet(
paymentIntentClientSecret: result.clientSecret,
configuration: config
)
paymentSheetPresented = true
The .paymentSheet() SwiftUI modifier handles presentation and returns a PaymentSheetResult — completed, canceled, or failed. On success, the app marks the invoice as paid locally. The webhook confirms it server-side.
I also built a payment link flow using Stripe Checkout Sessions. The business taps “Send Payment Link,” the API creates a hosted checkout page, and the app shares the URL via the system share sheet. The client pays in their browser. The app polls for completion.
Security model
The secret key never touches the client. The iOS app only receives a publishable key and a single-use client secret scoped to one payment. All sensitive operations happen server-side.
Auth uses Sign in with Apple with token-based verification. Every payment endpoint requires a valid auth token tied to a real Apple ID — a stolen publishable key alone can’t create charges.
What I’d do differently
Start with Stripe Connect from day one. I prototyped with direct charges first, then had to refactor to Connect when I realized each business needs its own account. If your app involves paying other people, you need Connect.
Don’t build custom card forms. PaymentSheet handles Apple Pay, card validation, 3D Secure, and error states. I spent zero time on payment UI and it looks better than anything I would have built.
Use webhooks from the start. I initially relied on client-side payment confirmation only. That works until someone’s phone loses signal after payment. The webhook is the source of truth — the app’s local status update is just optimistic UI.
Test your dollar-to-cents conversions. Floating-point math and currency don’t mix. Use NSDecimalNumber or Decimal carefully when converting between dollars and cents, and write tests for edge cases like $99.99. It’s easy to be off by a penny.
The result
Fescue users can take credit card payments on-site or send payment links via text — all from a native SwiftUI app backed by a lightweight server. No Lambda cold starts, no Cloudflare Worker edge cases, no Firebase functions. Just a small API and Stripe.
If you’re building an indie iOS app that needs payments, you don’t need a big backend. You need a small one that does exactly what Stripe requires and nothing more.