Two-sided marketplaces look simple on a whiteboard: supply on one side, demand on the other, and a booking button in the middle. Airbnb, Uber and Etsy all look like variations of the same diagram.
In practice, the diagram hides the hard part. Almost every real problem comes down to one question: who is allowed to see what, and at which moment?
I've been building FenceSpace, a mobile marketplace where people list their fence as local ad space and advertisers book it. This post isn't a product pitch. It's a write-up of the design problems we ran into and the approaches we took, in case you're building something similar, whether that's a marketplace, a booking tool, or any app where two groups of strangers need to trust each other.
Here's what we'll cover:
Splitting a listing into public and private data
Enforcing that split on the server, not in the UI
Modelling bookings as a state machine
Handling payments and webhooks safely
Building trust between strangers
Mistakes and lessons learned
- The core problem: sensitive data on a public listing
A fence sits at someone's home. That means every listing is tied to a real street address, and owners reasonably don't want that address visible to the whole internet.
But advertisers still need enough information to make a decision. They care about:
The neighbourhood or general area
The size of the space
How visible it is (foot traffic, road traffic, sight lines)
The price and availability
So the first design decision was to treat a listing as two layers of data instead of one flat record.
text
Public layer (anyone browsing)
- approximate area / neighbourhood
- size and dimensions
- photos
- visibility notes
- price and availability calendar
Private layer (revealed only after a confirmed booking)
- exact street address
- owner contact details
- access or on-site notes
This sounds obvious, but it changes how you design everything downstream. Your database schema, your API responses, your search index and your map pins all need to respect the same boundary.
Don't leak through the map
Maps are a classic way to leak location data by accident. If you place a pin at the exact coordinates of a listing, you've published the address, even if the text field says "hidden".
Common ways to handle this:
Store two coordinates: a precise one (private) and a fuzzed one (public).
Show an area, not a pin: draw a circle or neighbourhood region instead of a point.
Round or offset coordinates server-side before they ever reach the client.
Whichever you choose, do it on the server. Which brings us to the most important rule.
- Enforce it on the server, not in the UI
It's tempting to hide a field in the app and call it done. The problem is that the mobile app and the web client are untrusted environments. If the exact address is inside the JSON response and the UI just doesn't render it, anyone with a proxy tool like Charles, mitmproxy or even browser dev tools can read it.
The rule we follow: the private layer is never sent to the client until the booking reaches a confirmed state.
Here's a simplified example of what that looks like in a serializer. This is illustrative pseudocode, not our exact implementation:
js
function serializeListing(listing, viewer, booking) {
const publicData = {
id: listing.id,
title: listing.title,
area: listing.publicArea,
size: listing.size,
photos: listing.photos,
pricePerWeek: listing.pricePerWeek,
};
const canSeePrivate =
viewer.id === listing.ownerId ||
(booking && booking.advertiserId === viewer.id && isConfirmed(booking));
if (!canSeePrivate) return publicData;
return {
...publicData,
address: listing.address,
ownerContact: listing.ownerContact,
};
}
const CONFIRMED_STATES = ["confirmed", "completed", "reviewed"];
const isConfirmed = (booking) => CONFIRMED_STATES.includes(booking.status);
A few details worth calling out:
The default path returns the safe version. Access to private data is an explicit opt-in, so a forgotten check fails closed rather than open.
The permission check lives in one place. If every endpoint re-implements it, one of them will eventually get it wrong.
You should write tests for the negative case: "an advertiser with a pending booking must not receive the address." These are the tests that protect your users.
- Modelling the booking lifecycle as a state machine
A booking isn't a single action. It's a process that moves through stages, and different people can act at different stages. Here's a simplified version:
text
requested -> approved -> paid -> confirmed -> completed -> reviewed
| |
v v
declined cancelled
Writing the states down explicitly (and enforcing the allowed transitions in code) gave us several benefits.
Permissions become easy to reason about. The question "can this user see the address?" turns into "is this booking in confirmed or later?" That's one line instead of a tangle of conditions.
Edge cases become visible. Declines, cancellations and disputes each get a defined path instead of ad-hoc handling scattered across the codebase.
Notifications map cleanly. Each transition triggers exactly one message to exactly the right person. "Owner approved your request" and "Payment received" are transitions, not special cases.
Bugs get easier to find. When something goes wrong, you can ask "which transition was it in and who triggered it?" instead of piecing together a story from logs.
A minimal way to enforce allowed transitions:
js
const TRANSITIONS = {
requested: ["approved", "declined", "cancelled"],
approved: ["paid", "cancelled"],
paid: ["confirmed", "cancelled"],
confirmed: ["completed", "cancelled"],
completed: ["reviewed"],
declined: [],
cancelled: [],
reviewed: [],
};
function transition(booking, nextStatus, actor) {
const allowed = TRANSITIONS[booking.status] || [];
if (!allowed.includes(nextStatus)) {
throw new Error(Invalid transition: ${booking.status} -> ${nextStatus});
}
assertActorMayPerform(booking, nextStatus, actor);
return { ...booking, status: nextStatus, updatedAt: new Date() };
}
Notice the assertActorMayPerform step. Not every transition can be triggered by everyone. Only the owner should be able to approve a request, only the system (via a payment event) should move a booking to paid, and so on. Encoding "who can do what" alongside "what can follow what" prevents a whole class of abuse.
- Payments: let a provider handle the hard part
Handling money yourself is a liability you almost never want. We use Stripe for payment processing, which means card details never touch our own servers and the booking flow simply reacts to payment events.
If you're building anything similar, these are the lessons that matter most.
Drive state from webhooks, not from the client
The app telling your backend "payment succeeded" is a claim, not a fact. A user can tamper with a client, or the app can crash right after paying. The payment provider's webhook is the source of truth.
js
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers["stripe-signature"],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send("Invalid signature");
}
await handleStripeEvent(event);
res.sendStatus(200);
});
Always verify the signature. Without it, anyone who finds your webhook URL can mark bookings as paid.
Make handlers idempotent
Payment providers can deliver the same event more than once, and events can arrive out of order. Your handler needs to be safe to run repeatedly.
js
async function handleStripeEvent(event) {
const alreadyProcessed = await db.processedEvents.exists(event.id);
if (alreadyProcessed) return;
switch (event.type) {
case "payment_intent.succeeded":
await markBookingPaid(event.data.object);
break;
// handle other event types here
}
await db.processedEvents.insert({ id: event.id });
}
Storing processed event IDs is a simple, effective guard against double-processing. Ideally the check and the insert happen in the same database transaction as the state change.
Define "completed" before you take a commission
A common marketplace model is to take a small commission only when a booking is successfully completed. That aligns incentives nicely: the platform only earns when users do. But it forces you to answer an awkward question precisely: what counts as completed?
Is it the end date of the booking? Confirmation from both parties? A grace period with no dispute? Decide this early and encode it in the state machine, because changing the definition later means migrating live bookings and re-explaining it to users.
- Trust features are product features
When two strangers are about to do business, trust is not a nice-to-have. It's what the product is selling. A few small features carry a lot of weight.
Two-way reviews. After a booking, both sides review each other. Owners build a reputation for being reliable and easy to work with. Advertisers build a reputation for respecting the space and paying on time. This keeps both sides accountable, not just the supply side.
In-app messaging. Keeping the conversation inside the app means there's a record if something goes wrong. It also removes the temptation to exchange phone numbers too early, which would bypass the privacy rule from section 1.
Clear terms before confirmation. Both parties agree to the terms and the cancellation policy at booking time. It's much easier to resolve a dispute when the rules were visible before anything happened.
A human escape hatch. However good your automation is, some disputes need a person. A visible way to contact support, and a clear process for mediation, is worth more than another feature.
- What I'd do differently
[Add 2 to 3 honest lessons from your own build here. For example: something you over-engineered, a decision you reversed, a bug that taught you something, or something you wish you had tested earlier. Specific, honest details make a post far more valuable to readers than general advice.]
Tech stack
[List your actual stack here, for example: mobile framework, backend language and framework, database, hosting, and any third-party services. Developers on dev.to love this section, and it makes the post more useful for people deciding what to build with.]
Key takeaways
If you only remember a few things from this post:
Split sensitive data into layers and decide up front when each layer becomes visible.
Enforce visibility on the server. Hiding a field in the UI is not security.
Model workflows as explicit state machines so permissions, edge cases and notifications stay manageable.
Treat payment webhooks as the source of truth, verify signatures, and make handlers idempotent.
Design for trust with reviews, in-app messaging and clear terms.
Questions for you
I'd love to hear how others have tackled these problems:
How do you handle sensitive-field visibility in your APIs?
Do you model booking or order flows as explicit state machines, or something looser?
What's the nastiest edge case you've hit in a marketplace or payments flow?
Let me know in the comments.
Disclosure: I'm the builder of FenceSpace, the app mentioned in this post.