Problem
This field-service marketplace connects clients with freelancers. A client posts a job, freelancers send proposals, and the client hires one.
At first, payment was not part of this flow. Clients could not pay inside the app. Freelancers could not receive a payout through the platform. Payment happened outside the platform, where the platform could not see it.
Adding payments to the app also adds risks. These are the problems I had to fix:
- The app marked a payment as successful before the payment provider confirmed it. So an unpaid job could show as paid.
- A second tap on "Create account" could create two payout accounts for one freelancer.
- The onboarding return link had to prove it came from the server. Only then could it mark an account as verified.
- An expired onboarding link left the freelancer on an error page, with no next step.
- A client could pay the same freelancer twice from the proposal card.
- After a payment, the update that closed the job searched for it with the wrong id. So the wrong job, or no job, was marked as completed.
Solution
One payout account per freelancer. Freelancers create a Stripe Connect Express account from the app. The server refuses to create a second account for the same person. Payouts are set to manual, so the platform decides when money is paid out.
Onboarding links that cannot be forged. The return and refresh links carry a short-lived token signed by the server. When the freelancer returns, the server checks the token against the account. Only then does it mark the account as onboarded. If the link has expired, the refresh link creates a new one, so the freelancer is not stuck.
Onboarding opens in an in-app browser. When the browser closes, the app refreshes the account status immediately. So freelancers do not repeat onboarding because they think it failed.
A record for every payment. When a client hires and pays (with Klarna, through Stripe), the server creates the payment and a transaction record together. The record starts as pending. It stores the payment's id with a database index, so the webhook can find it quickly.
Only signed events change the status. The webhook reads the raw request and verifies Stripe's signature before it trusts anything. A succeeded event marks the transaction and the proposal as paid. It also closes the correct job. A failed event marks the transaction as failed. The app itself never decides that money has arrived.
- Client pays in the app
- Recorded as pending
- Signed event from Stripe
- Signature checked
- Succeeded: proposal paid, job completedFailed: marked failed, nothing else changes
No second payment. After a proposal's payment succeeds, its button shows "Already paid" and is disabled.
The trade-offs. Waiting for the webhook has a cost. The client sees the payment as pending for a moment before it changes to paid. Marking it as paid immediately would feel faster. But then unpaid jobs could show as paid. Both sides of a marketplace need to trust this status more than anything else.
Manual payouts are the second trade-off. The platform must start payouts itself, instead of using an automatic schedule. In return, the platform decides when money is paid out.
Impact
- A job shows as paid only after Stripe confirms the payment.
- Each freelancer has exactly one payout account.
- Onboarding links cannot be forged. An expired link leads to a new link, not an error page.
- A proposal that is already paid cannot be paid again from the app.
- The job that the client paid for is the job that is closed.