Design PayPal for mobile engineers
The first time I tried to reason about a PayPal-style app, I started with the obvious backend boxes: payments, database, banks, card networks.
That is part of it, but it is not the part users feel first.
On a phone, a payment product has to answer harder product questions: can I trust the balance I see, did my payment really go through, what happens if I lose signal after tapping Pay, and why did the app say pending when the merchant says paid?
Payments are not like chat messages. A duplicate chat message is annoying. A duplicate payment is scary. A stale unread badge is messy. A stale available balance can cost real money.
So I would design it from the phone inward: what the user sees, what the app can safely cache, how retries work, and where the server becomes the source of truth.
Under that UX are the pieces that make payments safe: payment intents, risk checks, idempotent execution, ledger writes, external settlement, notifications, webhooks, and reconciliation.
This post is part of my System design for mobile engineers series.
Problem statement
Design a PayPal-like mobile wallet and checkout system.
PayPal’s public mobile apps page frames the consumer product as more than checkout: send and receive money, shop, find deals, use QR codes, and manage wallet activity from the phone. I am using that as product context, but keeping this design focused on the core money-movement path so the architecture stays explainable.
The system should support:
- users with PayPal accounts
- balances, linked bank accounts, cards, and preferred payment methods
- peer-to-peer sends
- merchant checkout
- refunds
- payment history
- push notifications and email receipts
- risk checks and step-up authentication
- webhooks or callbacks for merchants
- mobile clients on unreliable networks
The mobile-specific goal is this:
The app should make money movement feel clear, safe, and recoverable, even when the network is unreliable.
That means the design needs strong server-side correctness, but also careful client behavior. The app should never guess that money moved. It can show pending local state, but the source of truth must be server-confirmed payment and ledger state.
Requirements
Functional requirements
PayPal is broader than this design: shopping, deals, rewards, package tracking, QR payments, crypto, account activity, disputes, chargebacks, business tools, and more. I would not include all of that in a first pass.
For this design, I would focus on the foundation most of those surfaces depend on: wallet home, funding methods, activity, risk, notifications, and ledger-backed payment state.
For a first version, I would include:
- Users can sign in and view a wallet home screen.
- Users can see their balance, rewards entry points, and recent activity.
- Users can link cards or bank accounts.
- Users can send money to another user.
- Users can request money from another user.
- Users can pay a merchant through a checkout or QR-style flow.
- Users can see pending, completed, failed, canceled, and refunded payments.
- Merchants can receive payment status updates.
- Users receive push notifications and receipts for important payment events.
- The app supports safe retries when the network is flaky.
I would defer the wider commerce, compliance, and business-tool surfaces until the core money movement path is understandable. The important decision is that the architecture should not block those surfaces later: wallet home, activity, notifications, risk, identity, and ledger state should be reusable foundations.
Non-functional requirements
The design needs:
- Exactly-once business effect per user confirmation, implemented with idempotency and server-side state.
- At-most-once money movement when a mobile client retries after an uncertain response.
- Durable ledger writes.
- Idempotent APIs for mobile retries.
- Strong auditability.
- Low latency for checkout authorization.
- Risk checks before money movement.
- Clear payment state transitions.
- Graceful handling of external network delays.
- No local client authority over balances or settlement state.
- Secure handling of credentials, device signals, and tokenized payment methods.
- PCI-scope minimization by avoiding raw card or bank credentials on the device.
- KYC, AML, sanctions, privacy, and regional requirements where they apply.
- Backward-compatible APIs for older app versions.
The phrase I keep coming back to is “recoverable certainty.” The app does not need to prove everything instantly, but it must give the user a trustworthy path from intent to final state.
Product behavior first
I would start with the behavior the phone has to get right.
Wallet home
The home screen should show:
- available balance
- pending balance when relevant
- linked funding methods
- recent activity
- alerts that require user action
- entry points for Send, Request, Pay, and Activity
The client can cache this data, but it should label freshness carefully. A cached activity list is useful. A cached balance should be refreshed before critical actions.
For example, if the user opens the app on a train with poor connectivity, it is fine to show the last known balance with a subtle stale indicator. It is not fine to let the user make decisions as if that balance is guaranteed current.
Send money
A send flow has several steps:
- Choose recipient.
- Enter amount and currency.
- Choose funding source.
- Review fees and estimated arrival.
- Confirm.
- Show pending or completed state.
- Send receipt and activity update.
The important mobile detail is the confirm tap. Once the user taps Confirm, the app should create a durable payment intent on the server with an idempotency key. If the network fails after the server accepts the request, the client should retry with the same key or query the intent status.
On Android, I would persist the in-flight intent ID and idempotency key before sending the confirm request. That state should survive process death, app restart, and a lost network response. The UI can show a pending row immediately, but it should reconcile from the server before claiming success.
It should not create a new payment just because the first response was lost.
Merchant checkout
A merchant checkout flow is similar, but the shape is different:
- Merchant creates an order or checkout session.
- User approves payment in PayPal.
- PayPal authorizes or captures the payment.
- Merchant receives a confirmation through an API response and later webhook events.
- User sees the transaction in PayPal activity.
PayPal’s public docs describe REST APIs, Orders API, Payments API, and Checkout integration. I would not try to recreate every product detail, but those docs show the shape I would expect: create intent, approve, capture or authorize, and reconcile asynchronous events.
Activity and receipts
The activity screen is not just a list. It is the user’s audit trail.
Each item needs a state:
- pending
- completed
- failed
- canceled
- refunded
- partially refunded
- under review
The UI should avoid vague states like “processing” without context. If a payment is pending because a bank transfer has not settled, say that. If it is under review because of risk checks, say that without exposing sensitive risk rules.
Scale assumptions
I would avoid pretending I know PayPal’s actual scale. For a design exercise, I would pick numbers that force the right tradeoffs:
- tens of millions of monthly active users
- millions of daily mobile sessions
- thousands to tens of thousands of payment attempts per second at peak
- high read volume on wallet home and activity
- lower but much more sensitive write volume for payment creation
- many external dependencies: banks, cards, fraud providers, email, push, merchant systems
The main consequence is that reads and writes should be separated. Wallet home and activity can be served through read models and caches. Payment execution and ledger writes need stricter control.
Core concepts
Payment intent
A payment intent represents what the user or merchant is trying to do.
I am using payment_intent as a generic system-design abstraction. In PayPal’s public APIs, the closest checkout resource is an Order, whose intent is usually CAPTURE or AUTHORIZE. For a design exercise, I still like the generic name because it keeps the idea clear: the system needs a durable object around the user’s intent.
It might include:
payment_intent
- id
- idempotency_key
- user_id
- recipient_id or merchant_id
- amount
- currency
- funding_source_id
- status
- risk_decision
- created_at
- updated_at
The intent gives the system a durable object to recover around. The mobile app can ask: what happened to intent pi_123?
That is better than asking: did my last HTTP request work?
Ledger entries
The ledger records money movement. This is the part I would treat as append-only.
A simplified double-entry ledger might look like:
ledger_entry
- id
- transaction_id
- account_id
- direction: debit | credit
- amount
- currency
- entry_type
- created_at
For a peer-to-peer send, the system might debit one user’s PayPal balance account and credit another user’s PayPal balance account. For card or bank-funded payments, there are more clearing and settlement accounts involved.
The key idea is that balances should be derived from ledger entries or updated by a tightly controlled ledger service. The mobile app should never be trusted to calculate final balance.
There is also a difference between current balance and available balance:
- Current balance: posted ledger entries.
- Available balance: current balance minus holds, reserves, and pending outbound payments.
- Pending balance: expected credits or debits that are not fully settled yet.
That distinction matters in the UI. A user may have received money, but some of it may not be available for spending yet.
Payment state machine
A state machine makes the product understandable and the system safer.
For example:
created -> requires_action -> approved -> processing -> completed
\ \ \
\ \ -> failed
\ -> canceled
-> expired
completed -> refunded
completed -> partially_refunded
Not every payment uses every state. A simple PayPal balance transfer might complete quickly. A bank-funded payment can remain pending. A suspicious payment can require step-up authentication or manual review.
The important part is that every transition is explicit, audited, and owned by the server.
High-level architecture
A first version could look like this:
Mobile app
-> API gateway
-> Identity and session service
-> Wallet read service
-> Payment intent service
-> Risk service
-> Ledger service
-> Funding service
-> Notification service
-> Merchant webhook service
-> Reconciliation jobs
Each service owns a different kind of correctness.
| Component | Responsibility |
|---|---|
| Mobile app | Local state, secure session, payment UX, retry behavior, receipts, and push handling |
| API gateway | Auth, rate limiting, request validation, and app version handling |
| Wallet read service | Balance summary, funding methods, and recent activity read models |
| Payment intent service | Payment attempts and state transitions |
| Risk service | Fraud checks, device signals, velocity checks, and step-up decisions |
| Ledger service | Append-only debits and credits, balance authority, and audit trail |
| Funding service | Cards, banks, PayPal balance, and external provider integrations |
| Notification service | Push notifications, email, in-app notifications, and receipts |
| Webhook service | Merchant callbacks, retry, signing, and delivery logs |
| Reconciliation jobs | Compare internal state with external processors and banks |
Here is the same system as a fuller map. I would not start an interview with this much detail, but it is useful as a checklist once the main payment flow is clear.

Mobile client state
The mobile app should maintain local state, but only for the right things.
Useful local state:
- cached wallet home response
- cached activity list
- draft send form
- pending payment intent ID
- idempotency keys for in-flight actions
- receipt views and notification deep links
- short-lived session state and token references stored with platform secure storage
- device risk signals that are safe and privacy-aware to collect
Dangerous local state:
- final balance authority
- final settlement status
- risk decision authority
- merchant fulfillment decision
- raw card or bank credentials
- whether a bank transfer cleared
The app can say, “I asked the server to create this payment and I am waiting for the result.” It cannot say, “Money moved” unless the server confirms it.
API shape
A simplified API might look like this:
For mobile retries, mutation requests should include an idempotency key:
PayPal documents request structure, responses, and idempotency concepts in its REST request docs, REST response docs, and idempotency guidelines. For PayPal REST APIs, idempotency is commonly expressed with the PayPal-Request-Id request header. The same request ID should be reused for safe retries of the same operation, while endpoint-specific retention windows and semantics still matter.
The confirm flow
The most important flow is payment confirmation.
1. User taps Confirm.
2. App creates or reuses a local idempotency key.
3. App sends confirm request.
4. API validates auth, device, amount, recipient, and funding source.
5. Risk service returns allow, deny, review, or step-up.
6. Payment service creates a durable state transition.
7. Funding service authorizes, captures, or initiates external movement when needed.
8. Ledger service records a hold, reserve, payable, receivable, or final debit and credit based on the payment state.
9. Notification and activity read models update asynchronously.
10. App receives final or pending status.
The client should handle several outcomes:
| Outcome | Mobile behavior |
|---|---|
| Completed | Show success, receipt, updated activity, and refreshed balance |
| Pending | Show a pending receipt with the reason and expected next step |
| Requires action | Ask for authentication, verification, or a funding source update |
| Failed | Show a safe failure state, preserve useful context, and allow retry when appropriate |
| Unknown | Query the payment intent by ID or retry with the same idempotency key |
The unknown case matters most on mobile. If the app times out after Confirm, it should not show a generic failure and invite the user to tap again blindly. It should recover around the intent.
Risk and step-up authentication
Risk checks should happen before money moves.
Signals might include:
- user account history
- recipient or merchant reputation
- device fingerprint signals
- app version
- IP and approximate location
- funding source age
- transaction amount and velocity
- recent failed login or password reset activity
The risk service should not be embedded inside the mobile app. The app can collect allowed signals and support step-up flows, but final risk decisions belong on the server.
A risk decision might return:
- allow
- deny
- require biometric confirmation
- require passcode or two-factor authentication
- require card verification
- send to manual review
From the user’s perspective, this should feel like a security checkpoint, not a random failure. The app should say what action is needed without exposing rules that attackers can game.
For card-funded checkout, step-up can include 3-D Secure or other SCA-style flows. For wallet and account flows, it may be passkey or biometric re-auth, OTP, device verification, account review, or identity verification. On mobile, any deep link or browser return from a step-up flow is only a hint. The app should fetch the server-owned intent or order state before claiming success.
Ledger and balance design
I would separate payment orchestration from ledger ownership.
The payment service answers: what is the state of this payment?
The ledger service answers: what debits and credits happened?
That separation matters because a payment can have several lifecycle events:
- authorization
- capture
- settlement
- refund
- reversal
- fee
- currency conversion
Each can produce ledger entries. The wallet home screen does not need to understand every lifecycle event, but the ledger must. Refunds, reversals, chargebacks, and fees should create compensating entries instead of mutating the original money movement.
For reads, I would use a balance projection:
ledger entries -> balance projector -> wallet read model -> mobile wallet home
The balance projection can be cached and optimized for reads. The source of truth remains the ledger.
Webhooks and merchant callbacks
Merchant checkout adds another recovery problem. The merchant needs to know what happened even if its API response was lost.
PayPal has public docs for webhooks and the general idea is important: payment platforms should send signed asynchronous events for state changes.
A webhook service should support:
- event creation from payment state transitions
- signed payloads
- merchant endpoint configuration
- retry with backoff
- delivery logs
- idempotent event IDs
- replay from dashboard or support tools
Webhooks are not just notifications. They are part of the consistency model between PayPal and merchants. For a PayPal-style implementation, merchants should verify webhook transmission headers and signatures, then treat webhook event IDs as idempotency keys for event processing. PayPal documents this in its webhook integration guide.
Push notifications and receipts
Push notifications are useful, but they are not a source of truth.
The app might receive:
- payment completed
- payment pending
- money received
- refund completed
- action required
- suspicious activity
When the user taps a push, the app should deep link to the relevant payment intent or activity item, then fetch current server state. The push payload can include an ID and summary, but the app should not rely on it as final proof. The payment detail endpoint should be more authoritative than an eventually consistent activity list.
Receipts should come from server-confirmed payment events. They can be displayed in-app and sent by email. If push is delayed, the activity screen should still show the right state after sync.
Offline and flaky-network behavior
A PayPal-style app should not support fully offline payment creation. That would be too dangerous.
But it can support useful offline behavior:
- show cached activity
- show cached funding methods with stale indicators
- preserve draft send details locally
- keep pending intent IDs and idempotency keys
- retry safe status reads with backoff and jitter
- resume confirmation after reconnect or app restart
The app should be conservative with money movement. If it cannot reach the server, it should not pretend a payment happened.
The hardest case is this:
The server completed the payment, but the response never reached the phone.
The recovery path is:
- Keep the pending local record.
- Retry with the same idempotency key, or fetch by intent ID.
- Replace the local pending row with server state.
- Refresh wallet home and activity.
- Show a clear receipt or failure message.
Data model
A simplified data model might include:
user
- id
- status
- created_at
wallet_account
- id
- user_id
- currency
- status
funding_source
- id
- user_id
- type
- token_reference
- status
- last4
- network
payment_intent
- id
- idempotency_key
- client_request_id
- sender_user_id
- recipient_user_id
- merchant_id
- merchant_order_id
- external_order_id
- authorization_id
- capture_id
- amount
- currency
- funding_source_id
- status
- version
- created_at
- updated_at
payment_event
- id
- payment_intent_id
- event_type
- from_status
- to_status
- event_sequence
- metadata
- created_at
ledger_entry
- id
- transaction_id
- account_id
- direction
- amount
- currency
- entry_type
- created_at
webhook_event
- id
- merchant_id
- event_type
- resource_id
- delivery_status
- created_at
I would index around access patterns:
payment_intent(user_id, created_at)
payment_intent(idempotency_key)
payment_intent(merchant_order_id)
payment_intent(external_order_id)
payment_event(payment_intent_id, created_at)
ledger_entry(account_id, created_at)
webhook_event(merchant_id, created_at)
The idempotency key index should be scoped carefully. Usually it is not global forever. It might be scoped to user, endpoint, and time window depending on product rules.
Caching and read models
Wallet home and activity are read-heavy.
I would not make the mobile app assemble them from raw ledger entries. Instead:
- ledger service emits events
- payment service emits state changes
- read model projectors update wallet and activity views
- wallet service serves mobile-optimized payloads
The wallet home response should be compact and versioned:
Including asOf and serverTime helps the app show freshness and reason about stale local state.
Rate limiting and abuse controls
Payment APIs need abuse controls at several layers:
- login attempts
- funding source linking
- payment creation
- recipient search
- checkout attempts
- webhook delivery configuration
PayPal documents rate limiting, and the broad idea applies here. Limits should protect both platform availability and fraud surfaces.
The mobile app should translate rate-limit responses into clear UX. “Try again later” is sometimes enough, but payment flows often need more specific recovery paths, such as verifying identity or choosing another funding source.
Observability
For a payment app, observability has to connect client symptoms to server state.
I would track:
- confirm tap to server accepted latency
- confirm tap to final state latency
- unknown outcome rate after confirm
- idempotency replay rate
- duplicate prevention count
- payment success rate by funding type
- risk step-up completion rate
- pending payment aging
- webhook delivery success rate
- push delivery to activity sync delay
- app version specific failure spikes
The mobile app should attach a request ID or trace ID to each payment attempt. Support tooling should let an operator search by user, payment intent, idempotency key, or merchant order ID.
Failure modes
The response is lost after payment succeeds
This is the classic mobile payments failure.
The server committed the payment, but the app timed out. The app should recover through the payment intent, not start over.
External processor is slow
The payment can remain pending. The app should explain why, update activity, and notify the user when the state changes.
App is killed during confirm
The app should persist the idempotency key and pending intent before sending the confirm request. On next launch, it should reconcile the in-flight payment by intent ID or idempotency key. It should not blindly submit a fresh payment just because the previous process died.
User double taps Confirm
The client should disable the confirm button after the first tap, keep the in-flight state visible, and use the same idempotency key for retries. Idempotency protects server correctness, but client behavior still protects user trust and backend load.
Risk requires step-up
The app should route the user into the step-up flow and keep the original intent alive. If the user abandons the flow, the payment should remain in a safe state like requires_action or expired. If a browser, Custom Tab, or app-to-app redirect returns to the app, the client should still fetch server state before showing success.
Push notification arrives before the activity read model updates
The app should fetch the payment intent directly, then refresh activity. It should not assume the list is wrong forever.
Merchant return URL or webhook fails
The user’s browser or merchant app may fail to return after approval. The merchant should query PayPal-style order status server-to-server and rely on capture or webhook state, not only on the client return URL.
The webhook service should retry and expose delivery logs. The user’s payment can be complete even if the merchant has not processed the callback yet, but fulfillment may wait for merchant confirmation.
Old app version sends an older request shape
The API gateway should support older request versions or return a clear upgrade-required response before the user reaches the final confirm step.
What I would draw in a design review
I would draw the system around the payment intent and ledger, not around a generic payment database.
The story would be:
- Mobile app creates intent with idempotency key.
- Payment service owns state transitions.
- Risk service gates dangerous transitions.
- Ledger service records durable money movement.
- Funding service talks to external networks.
- Read models serve wallet home and activity.
- Notifications and webhooks communicate state changes.
- Reconciliation jobs repair drift between internal state, external processors, banks, and merchant callbacks.
That keeps the answer centered on correctness and recoverability.
What I would think through next
The main lesson is that mobile payment design is not about making the happy path fast. It is about making the uncertain path recoverable. If the phone loses the response, the app should still know what to ask the server, what to show the user, and what not to assume.
If I wanted to keep going, I would go deeper on:
- disputes and chargebacks
- buyer and seller protection
- bank transfer settlement windows
- card network authorization and capture details
- multi-currency balances and FX
- merchant onboarding and compliance
- privacy and security for mobile device signals
- support tooling for payment investigations
Further reading:
- PayPal: Mobile apps and digital wallet
- PayPal Developer: REST APIs
- PayPal Developer: Orders API
- PayPal Developer: Payments API
- PayPal Developer: Checkout
- PayPal Developer: Webhooks
- PayPal Developer: Idempotency guidelines
- PayPal Developer: Rate limiting
- PayPal Developer: REST requests
- PayPal Developer: Webhook integration
- PayPal Developer: Payment method tokens
- Modern Treasury: Accounting for developers
Now that you have read it, try the PayPal mobile design quiz. Twelve questions, about ten minutes, with explanations for each answer.
If this was useful, you can buy me a coffee ☕. If you have a question, correction, or a product you want me to think through next, leave a comment.
If you have seen a version of this question in an interview, I would love to hear what part felt hardest: requirements, APIs, mobile state, scale, offline behavior, or tradeoffs.
Comments
Loading…