One payment record for every hire
Clients hire freelancers through the marketplace and pay inside the app. I tied each hire to one payment record that only Stripe can mark as paid, so a paid job is locked on both sides and both sides see the same record.
- Market
- Field-service marketplace, Sweden
- My role
- Engineer on the app, API and dashboard
- Payments
- Stripe
- Who pays whom
- Client pays freelancer, through the marketplace
ProblemClients pay for the freelancer they hire, and both sides need one record of that payment that they can trust.
What I didI built the payment at hiring, a shared Stripe package, payment status on job cards, a transaction history, and a webhook that marks the job paid.
ResultA paid hire is locked in the app and refused by the server, the marketplace earns on every hire, and follow-up work is paid inside it too.
A hire without a payment record is a dispute waiting to happen
The marketplace connects people who need work done at their home or business with the freelancers who do it. A client posts a job through a six-step form, with photos, a category and the skills needed. Freelancers send proposals. The client talks to them in the app, picks one, and pays. The marketplace earns only when that payment goes through it.
That last step carries all the risk. If the client pays and the app does not record it, the freelancer waits for money that has already left the client's account. If the app shows a job as paid when the payment actually failed, the freelancer turns up for work nobody paid for. If a client can pay the same proposal twice, the marketplace has to refund it and explain why.
Both sides also need to see the same thing. The client wants to know the job is paid and the freelancer is on the way. The freelancer wants to know the money is real before they buy materials and drive across town. A job card that says one thing to the client and another to the freelancer starts an argument that support has to settle.
So the question I worked on was simple to say: how do we make sure each hire is paid exactly once, and that everyone sees the true state of that payment?
The phone does not know whether the payment worked
The first difficulty is where the truth lives. When a client pays, the payment is decided by Stripe and the client's bank, not by our app. The phone only sees a payment sheet that closes. It might close because the payment worked, because the client cancelled, or because the network dropped at the wrong moment. If the app decides the job is paid based on what the phone saw, sooner or later it will be wrong.
The second difficulty is that several records have to move together. A successful payment changes the transaction, the job post, the proposal and the freelancer's balance. If one of those updates and the others do not, the client's screen and the freelancer's screen disagree.
The third difficulty is the person holding the phone. People tap a button again when nothing seems to happen. They go back and try again. They open the app on a second device. Every one of those is normal behaviour, and none of them should turn into a second payment for a job that is already paid.
The last difficulty came later. Jobs do not always end when they end. After an urgent job, a client often needs follow-up work: more materials, more hours, a second visit. That work also has to be priced, agreed and paid, and the same rules have to hold for it.
One proposal, one payment record
Everything starts with a proposal. A freelancer offers to do the job at a price, and the client chooses one. The proposal is the anchor for the money. The payment record, which we call a transaction, points to the proposal, the job post, the client's account and the freelancer's account.
I added the account table and extended the transaction table for this. An account links a user to Stripe: a customer record for the client who pays, and a connected account for the freelancer who gets paid. A transaction stores the amount, the marketplace's fee, the Stripe payment ID and a status: pending, success or failed.
Before a freelancer can be paid at all, they set up their payout account with Stripe's onboarding. I built the first version of that flow in the app, with the return and refresh pages Stripe needs when the freelancer comes back from its hosted forms. The app does not hold bank details. Stripe does, and our database only knows whether the freelancer is ready to receive money.
Tying the money to the proposal, not to the screen, gave us one place to ask the question that matters: has this proposal been paid?
The server creates the payment, the app only shows it
When the client taps to pay, the app asks the API to create a payment for that proposal. The server checks who is asking, looks up the proposal and the existing transaction, and creates a Stripe payment. The payment goes to the freelancer's connected account, and the marketplace keeps its fee. The transaction is saved as pending, with the Stripe payment ID on it.
On the app side I built the payment screen and the components around it. The app receives what it needs from the server, shows Stripe's payment sheet, and then waits. It does not write "paid" anywhere. A small group of Stripe routes in the app catches the return from Stripe's pages and sends the user back to the right screen.
I put the Stripe setup in one shared package inside the monorepo. The API, the web app and the webhook all import the same client, with the same keys and the same API version. When we later had to move to a newer Stripe API version, it was a change in one place, not a search across three apps.
This step had a trade-off. Creating the payment on the server adds a round trip before the payment sheet opens. In return, the amount, the fee and the destination account come from our database, not from whatever the phone sends.
Only the webhook can say a job is paid
Stripe tells the server about the result with a webhook: a signed message sent to our API when a payment succeeds or fails. The server checks the signature first, so nobody can fake a payment by calling the endpoint.
When the payment succeeded event arrives, the server finds the transaction by its Stripe payment ID and marks it as a success. I added the step that then marks the job post as completed. The same handler marks the proposal as paid and adds the amount, minus the fee, to the freelancer's balance in our records. When the failed event arrives, the transaction is marked as failed and nothing else moves.
This is the most important decision in the whole flow. The phone can close, lose signal or crash the moment after payment. None of that matters, because the phone never decides. Stripe's message reaches the server whether the client's app is open or not, and the server updates every record from that one event.
The cost is a short delay. For a moment after paying, the client may see the job as pending, until the webhook arrives. That is the trade-off: an honest "pending" for a few seconds instead of a fast "paid" that might be false.
What the records say as a hire is paid
| Event | State | Amount |
|---|---|---|
| Client chooses a freelancer's proposal | Chosen | 1,000.00 kr |
| Server creates the Stripe payment | Pending | 1,000.00 kr |
| Stripe sends the payment succeeded event | Success | 1,000.00 kr |
| Job post marked completed, proposal marked paid | Paid | 1,000.00 kr |
| Freelancer's balance in our records | Credited | Amount minus fee |
| Client tries to pay the same proposal again | Refused | 0.00 kr |
A paid hire cannot be paid again
Once the transaction for a proposal is a success, the hire button on that proposal is disabled. I changed the job match card so it reads the real transaction status from the server, not a flag held in the app's memory. A client who comes back later, on the same phone or another one, sees the same locked button.
The button is the friendly half of the lock. The server is the strict half. If a request to pay that proposal arrives anyway, from an old screen or a second device, the server sees that the transaction already succeeded and refuses with a clear message: the payment is already completed. No second Stripe payment is created.
Both halves matter. A disabled button alone is not a guarantee, because an app can be out of date or a request can be sent twice. A server check alone gives a poor experience, because the client taps, waits and gets an error. Together, the normal case is clear and the unusual case is safe.
Messaging followed the same rule. A conversation about a job that was ongoing could not be archived, so neither side lost the thread of a job that was still in progress.
The same payment state on every card
I added the payment status to the job cards on both sides: the client's list of their jobs and the freelancer's list of jobs they work on. The status comes from the same transaction record, so the two cards cannot disagree.
I also built the transaction history screen. A freelancer sees their payout account information, an overview, and a list of transactions, each with its amount and status. When someone asks "did this payment go through?", the answer is on a screen, not in a support conversation.
This was the part users actually saw. The webhook and the transaction table are invisible. The card that says "paid" and the history that lists the payment are what a client needs to see before they trust the app with the next hire.
The choices behind the payment flow
Who marks the job as paid?
The app, after the payment sheet closes
The server, when Stripe's signed webhook confirms the payment
The phone can close or lose signal at any moment. Stripe's message reaches the server either way.
How do we stop a second payment for the same hire?
Trust the user to tap once
Disable the hire button after a successful transaction, and refuse it on the server too
The button handles the normal case kindly. The server check handles old screens and second devices.
Where does the Stripe code live?
Copy the setup into each app
One shared Stripe package in the monorepo
One client, one set of keys and one API version. An upgrade is one change.
What is the payment tied to?
The job post as a whole
The proposal the client accepted
The proposal holds the price and the freelancer. It is the thing the client is actually paying for.
The webhook decides, and the second payment is refused
async function onPaymentSucceeded(paymentId: string) {
const tx = await transactions.findByStripeId(paymentId);
if (!tx) throw new Error("Unknown payment");
await transactions.setStatus(tx.id, "SUCCESS");
await posts.markCompleted(tx.postId);
await proposals.markPaid(tx.proposalId);
}
async function createPayment(proposalId: number, clientId: number) {
const tx = await transactions.findByProposal(proposalId);
if (tx?.status === "SUCCESS") throw new Error("Payment already completed");
// create the Stripe payment for this proposal and save it as PENDING
}When the job is done but the work is not
Later, together with a teammate, I worked on what happens after an urgent job. Often the first visit fixes the immediate problem and the client needs more: extra materials, extra hours, or a second visit. Before this, that follow-up work could happen outside the app, which meant outside the marketplace's records and outside its earnings.
We built a maintenance workflow for it. The freelancer can price the follow-up work, add the materials needed, and enter an overtime fee by hand when the job took longer than planned. The client sees the breakdown and pays it in the app. Each role sees only the actions that are valid for them at that point. For example, a freelancer cannot change the overtime fee after it has been paid.
The payment for maintenance follows the same rule as the first hire. The Stripe webhook is the only thing that moves it forward. We also corrected the webhook so that a successful payment for a maintenance job marks the maintenance as completed and the job as done. Before that fix, the payment could go through while the maintenance job stayed in its old status.
The maintenance details also appear on the invoice and the PDF receipt, so the client's record of what they paid for matches what was done.
What changed for clients, freelancers and the marketplace
One record that both sides trust
With this work in place, every paid hire has one transaction, tied to one proposal, moved only by Stripe's signed messages. The client's card, the freelancer's card and the transaction history all read from that record.
For the business, this meant the core loop of the marketplace was complete: post a job, receive proposals, talk, hire, pay, and follow up. Each step happens in the app, and the step that earns money is recorded in a way the marketplace can rely on.
For me, it was also a lesson in where to spend effort. The visible part, the payment screen, took the least thought. The decisions about which system is allowed to say "paid" took the most, and they are the ones that protect everyone when something goes wrong.
What I would carry into the next payment flow
Let the payment provider be the source of truth. The app shows the state, the server records it, and only the provider's signed message changes it. It is the rule I start every payment flow from now.
Lock in two places. A disabled button makes the right action obvious. A server check makes the wrong action impossible. Either one alone leaves a gap.
Tie money to the thing being bought. Here that was the proposal, because it carries the price and the person doing the work. Tying the payment to something broader, like the whole job post, would have made it harder to tell which offer was actually paid.
Next time, I would also lock the pending window more tightly. Our lock starts when a payment succeeds. A stricter design reuses the same pending payment if the client tries again before Stripe has answered, and makes the webhook safe to receive more than once. Those two steps close the gap between "started" and "confirmed", where most payment edge cases live.
Building a marketplace where people pay each other?
If your clients and providers need to trust the same payment record, I can help you design who decides "paid", and build it. Let's talk.