By Craig Vincent, Lead Programmer at Accent
What we learned building inside Reapit's AppMarket
We put SignMaster's board management inside Reapit as an extension: a React app living inside another company's platform, wired to a .NET backend that reacts to Reapit's events. A summary of the architecture, the code we're proud of, and the parts that made us earn it.
Building an app that lives inside someone else's platform is a bit like being invited to add an extension to a house you don't own: the foundations and plumbing come for free, you even get to use the nice kitchen - but you can't move a supporting wall, and everything you build has to look like it was always there.
We recently did exactly that. Agency Express supplies and manages the physical "For Sale" and "To Let" boards you see outside properties; their SignMaster system handles ordering, changing and collecting them. Reapit is the CRM many estate agents live in all day. Our job was to put SignMaster's board management inside Reapit - so an agent can order a board without leaving the software they already know, and so the boards quietly keep up as a property moves along its journey.

This is the honest write-up - the tour we'd have wanted before we started. What the integration looks like, the code we're happy with, and the places where the platform had more to teach us than we first expected. If you're about to build on someone else's platform, Reapit or otherwise, pull up a chair.
The shape of it
Two pieces do the work. A React app, embedded in Reapit's marketplace and signed in through Reapit Connect, gives agents the interface - it's built with Reapit's own Elements component library, so it looks like it belongs. Behind it sits a .NET service (MassTransit over RabbitMQ) that owns the board logic and, crucially, listens for what Reapit is doing.
That second, invisible path is the interesting one: Reapit tells us when a property changes, and a board action follows without anyone clicking a thing. When an event lands, it flows through:

Property changes → Verify (Ed25519) → De-duplicate → Serialise per property → Recompute board state → Reconcile / suspend
The webhook returns 202 Accepted once the event is safely queued; every stage assumes messages that might arrive twice, or out of order.
Everything that follows is really one idea - how to be a good guest in someone else's house - from four angles: reacting to their events, understanding their model (the one that taught us the most), borrowing their identity, and living inside their design system. There's a fifth thread running through all of them - the people who actually use the boards - and it turned out to matter as much as any of the code.
Events - Let the platform tell you what happened, then reconcile, don't obey
The most useful part of this whole integration is the part nobody clicks. When a property changes in Reapit - listed, under offer, sold, withdrawn - Reapit sends us a webhook (verified with an Ed25519 signature), and a board action quietly follows: put a For Sale board up, change it to Sold, schedule a collection.
The naïve version is a handler that simply does what the message says. That version breaks in production, because platform webhooks arrive with two facts you have to make your peace with early: they're delivered at least once (so you'll see duplicates), and they can turn up out of order (so "latest" is a polite fiction).
So the first thing our handler does is refuse to do the same job twice:
C# · ReapitPropertyModifiedConsumer.cs
// Reapit delivers each webhook at least once, so the first thing we do// is check the event's id against everything we've already handled.var eventAlreadyProcessed = _reapitDbContext.ReapitPropertyUpdates .Any(rpu => rpu.Id == context.Message.EventId);if (eventAlreadyProcessed){ await SendFailureResponseAsync(context, "Event has already been processed."); return;}And because two events for the same property can land within a whisker of each other, we process events for a given property strictly one at a time, while letting different properties run in parallel. MassTransit's partitioner does this in-process, no distributed lock required:
// Partition by EntityId (the Reapit property id). Events for the SAME// property run one-at-a-time; different properties run concurrently.consumerConfigurator.Message<ReapitPropertyModified>(m => m.UsePartitioner(16, ctx => ctx.Message.EntityId));The bigger decision is what an event actually means. We don't treat it as an instruction to replay; we treat it as a fact about where the property is right now, and recompute what the board should be from scratch. That way a duplicate or a slightly stale event lands on the same answer instead of double-acting:
C# · BoardMovementCalculator.cs
// We don't trust the event to be an instruction. We recompute what the// board should be from the property's *current* status.var erectOrChangeMovementType = isActive ? SignMasterMovementTypes.ChangeTo : SignMasterMovementTypes.NewListing;// Withdrawn (or no longer actively marketed) while a sign is up -> retrieve it.if (isActive && (!shouldBeActive || newStatus == ReapitPropertySaleStatus.Withdrawn)) return retrieve;if (newStatus == ReapitPropertySaleStatus.ForSale) return new() { new(erectOrChangeMovementType, false, SignMasterBoardStatuses.ForSale) };if (ReapitPropertySaleStatus.SoldStatuses.Contains(newStatus)) return new() { new(erectOrChangeMovementType, false, signMasterSoldBoardStatus) };Trimmed - the same method also handles under-offer, completion, and the "nothing actually changed" no-ops.
The genuinely hard case, though, isn't the software disagreeing with Reapit - it's the software disagreeing with a person. If an agent has already changed a board by hand, the last thing we want is automation bulldozing their decision. So when a pending manual change conflicts with what automation intends, we suspend automation for that property and let the human win - noting which event caused the suspension, so we can explain ourselves later. Working out that rule - who wins when the system and a real person reach for the same board - was far more of the job than any of the wiring.
Takeaway - Treat platform events as at-least-once and possibly out-of-order. Make every handler idempotent, recompute state rather than replaying instructions, and decide on day one what happens when your automation and a real user disagree.
The one that taught us the most - Sales was tidy, lettings led a double life
Here's the interesting part. It's tempting to treat lettings as just sales with different nouns - swap "For Sale" for "To Let", "Sold" for "Let By" - and we started there too. Lettings turned out to be richer than that, and working out why was the best lesson of the project.
Sales is honest about itself. A Reapit sales status is essentially one fact - how far the sale has got - and it maps almost straight onto a board: forSale puts a board up, sold swaps in a Sold slip, withdrawn takes it down. Better still, a sale is a journey with a clear beginning, middle and end - for sale → under offer → sold → collected - and the board simply follows the transitions. A brand-new letting works the same way (to let → tenancy agreed → let by), so that path came together early too.
The case with the most to teach is the property that is already tenanted. It has no journey for the board to follow. It doesn't start at "to let" and progress; it's simply let, and every so often it's put back on the market for the next tenant and then taken off again. There's no new-listing moment and no clean run of transitions - just an availability flag flipping on and off while the tenancy stage never moves. Our whole automation was built to react to transitions, and here there aren't any.
Underneath, that's one status carrying two facts at once. A lettings status encodes both the tenancy stage (how far the let has progressed) and whether the property is being marketed right now (that's the Unavailable suffix) - and for an already-tenanted property, only the second one ever moves. That second fact is easy to miss: the natural first reading groups the statuses into families, which quietly folds the Unavailable variants in with their marketed twins:
C# · ReapitPropertyLetStatus.cs
// A flat reading of letting.status groups the statuses into families. Notice// the "...Unavailable" variants (property NOT currently marketed) sit right// alongside their marketed twins - so availability isn't visible here.public static readonly string[] ArrangingTenancyStatuses ={ ArrangingTenancy, ArrangingTenancyUnavailable, // being marketed? couldn't tell UnderOffer, UnderOfferUnavailable,};That distinction matters the moment you meet a real letting. A tenanted flat, quietly back on the market for its next tenant, flips between "available" and "unavailable" without its stage ever changing. A model that can't see availability would read that flat as freshly let - or, when a tenancy "finishes", assume the instruction is over and bring the board in, even though the agent is often about to re-let and wants it to stay. The board needs to follow the marketing, not the tenancy.
The real trap is that the same status can mean opposite things, and with no transition to lean on there's nothing to tell them apart:
Same Reapit status: arrangingTenancy | What the agent means | Board they want |
|---|---|---|
| On a new let | "We've found a tenant" | Let By slip |
| On a re-let | "Back on the market for the next tenant" | To Let board |
One word, two workflows, opposite boards - and for an already-tenanted property there's no transitional sequence to disambiguate them at all. No amount of clever handler code fixes that, because the ambiguity isn't in the code; it's in the domain.
So the model we moved to reads the status as two axes - (stage, availability) - and a property can sit still at "tenanted" while simply moving along the availability axis, board and all. We wrote the decision table up as an architecture decision record before touching the calculator, because the interesting work was the understanding, not the typing. The transitional cases - sales and new lets - came together early; the already-tenanted flow is the newest piece, and getting it right is exactly where modelling the domain first pays off.
The one lesson we'd carry to the next project: the difficulty in an integration is rarely in your code - it's in how completely you understand the other system's model, including the shapes that don't move through it in a straight line. Sales we understood on day one; lettings had more to teach, and following where it led is what made the integration solid.
Takeaway - Before you automate someone else's workflow, make sure you understand every shape it comes in - including the ones with no neat start-to-finish transition, like a property that's already tenanted. Model the domain first; the code is the easy part.
Identity - Two platforms, two identities
Reapit and SignMaster don't share a login, and dealing with the realities of an older authentication system in SignMaster was the first challenge. The agent signs in through Reapit Connect, and that identity is perfect for Reapit's own APIs - but SignMaster has its own accounts and has never heard of them.
We bridge the two with a per-agency key the agency saves once, tucked into Reapit's own app metadata; SignMaster swaps it for its own session, and we map each Reapit office to the matching SignMaster customer so an order lands against the right account. The nice part is that the "join" between the systems lives in the host's storage - nothing extra for the agent to babysit, nothing bespoke for us to host.
The first knot to untie was the sign-in itself. Launched inside the marketplace, the OAuth redirect could loop: Reapit hands the app a deep link saying which property to open, and that little parameter was getting dropped as the app bounced out to sign in and back. The fix was small once we saw it: carry the launch parameters through the redirect, and act on the "open this property" instruction exactly once rather than on every render:
TSX · MarketplaceRedirect.tsx
// The marketplace injects prpCode into a window global for the whole page// session. We honour it only the first time the app opens - otherwise every// later visit to "/" bounces back to the property and the welcome page is// unreachable.let hasHandledInitialDeepLink = falseconst prpCode = (window as any).__REAPIT_MARKETPLACE_GLOBALS__?.prpCodeconst shouldRedirect = Boolean(prpCode) && !hasHandledInitialDeepLinkuseEffect(() => { if (!connectSession || !shouldRedirect) return // wait for auth first hasHandledInitialDeepLink = true // only ever once navigate(buildPropertyDetailPageURL({ propertyId: String(prpCode), tab: 'overview' }), { replace: true })}, [navigate, connectSession, prpCode, shouldRedirect])It's the kind of wrinkle you only meet because you're a guest: on its own, the app's auth was perfectly happy; it was the host's launch-and-redirect dance that surfaced it.
Takeaway - When two systems each own part of the user's identity, the integration's real job is the mapping between them. Decide early where that mapping lives - we put it in the host's own metadata - and give the sign-in round-trip extra attention; for an embedded app, that's where the surprises tend to live.
Design system - Use the host's toolkit, and know where it stops
Reapit ships a component library, Elements, and leaning on it is what makes an app feel native instead of bolted on. We got the navigation, tables, drawers and forms essentially for free, and our screens inherited Reapit's spacing and interaction language without us having to decide any of it - which is exactly what you want when the user is trying to get a job done, not admire the furniture.
The credibility, though, is in knowing where a design system runs out - and being honest about it in the code. Elements had no component for the little status chips we needed, so we built our own out of Elements' own CSS variables, so a component it doesn't ship still matches everything it does.
TS · utils/chips/index.ts
import { styled } from '@linaria/react'export const DisplayChip = styled.div` background: var(--color-grey-light); color: var(--color-grey-dark); font-size: var(--font-size-small); border-radius: 1rem; padding: 0.25rem 0.625rem;`Those are the very chips in the board-lifecycle line above - and the "To Let" / "Let By" ones a couple of sections ago - styled from Elements' tokens so they stay on-theme.
The other kind of friction has nothing to do with components at all - it comes from where the app runs. Because it lives inside Reapit, agents leave it open for days, so a tab can happily be serving a build we shipped last week. A standalone site gets reloaded often enough not to notice; embedded, we had to teach the app to spot when it has fallen behind and refresh itself. The part we like is the safety catch - a refresh that doesn't fix things must never turn into an endless reload loop:
TS · version-check.ts
const RELOAD_GUARD_KEY = 'version-check:reloads'const RELOAD_WINDOW_MS = 60_000const MAX_RELOADS_PER_WINDOW = 2// Reload timestamps from the last minute, kept in sessionStorage so the// count survives the reload itself.const recentReloads = (now: number): number[] => { const raw = sessionStorage.getItem(RELOAD_GUARD_KEY) const events = raw ? (JSON.parse(raw) as number[]) : [] return events.filter((t) => now - t < RELOAD_WINDOW_MS)}// A new build shipped while this tab was open - reload to pick it up, but// only if we haven't already reloaded twice this minute. A worker that// won't activate then can't spin the page in a refresh loop.export const canReload = (): boolean => recentReloads(Date.now()).length < MAX_RELOADS_PER_WINDOWWe also skip the refresh entirely while someone is mid-typing, so catching up with a new build never yanks the page out from under a user.
Takeaway - Adopt the host's design system wholesale - it's most of what makes you feel native; where you must step outside it, build from its own primitives so the seams don't show. And remember you're a guest in a long-lived host: an embedded app has to keep itself fresh in tabs that never close.
Formalising the process - one automation, many ways of working
Here's something we didn't expect to spend so much time on: not the automation itself, but formalising the process it should follow. The "correct" board behaviour isn't a single technical fact - agencies work in slightly different ways. Take a sold property. When Reapit reports a status of sold, everyone agrees a Sold slip goes up. But Reapit also has soldUnavailable - sold, and no longer actively marketed - and here agencies genuinely differ: some want the Sold slip for both sold and soldUnavailable, others only for a plain sold. Neither is wrong; they're simply different ways of working.
So a good chunk of the early rollout wasn't code at all - it was formalising the process. That meant agreeing a sensible default, taking it back to agents, listening to how they actually work, and - just as often - helping users see which Reapit status leads to which board, so the automation and their expectations line up. A board is a physical, visible thing: when one goes up a day early or comes down a day late, people notice. Getting the convention right matters every bit as much as getting the code right.
And we haven't done it alone. Agency Express's head office team have been in it with us - fielding questions, agreeing the defaults, and supporting agencies as they settle into the automated workflow. That turned what could have been a cold "here's your integration" handover into a genuinely joint effort, and it's a big part of why the rollout has gone as smoothly as it has.
Takeaway - When you automate a real-world workflow, expect part of the job to be formalising that workflow, not just building it. Different users hold different conventions, all of them reasonable - so leave room to agree sensible defaults, gather feedback, and support people through the change, ideally alongside the partner who knows those users best.
Where we'd take it next
A few directions we're keen to take this. Today each board action is decided from a snapshot of the property's status when an event lands, with deliberately light guarantees about ordering; it has held up well, and the natural next step is fuller handling of missed or out-of-order events - reconstructing intended state and recovering from a gap. We'd also go looking early for the shapes that don't follow a neat transition - the already-tenanted property chief among them - since that's where the real understanding of a domain lives. On a real integration, the edge cases are the project.
The change we're most looking forward to, though, is on the identity side. The per-agency key does its job today, but we're planning to upgrade SignMaster's authentication so that Reapit Connect can act as a single sign-on provider - one unified way in, rather than two identities bridged by a key. It would let a Reapit user arrive in SignMaster already signed in, and give us much finer control over which SignMaster areas each Reapit user can reach.
The throughline
Building inside another company's platform isn't a smaller version of building your own product. It's a different job: you react to someone else's events, you learn someone else's model in full, you borrow someone else's identity, and you live inside someone else's design system. The teams who do it well are the ones who treat the seams - idempotency, the domain you didn't write, identity mapping, the places the component library stops - as the actual work, not the tidy-up. And the last stretch isn't technical at all: agreeing the process with the people who'll use it, alongside a partner who knows them, is what turns a working integration into one people actually trust.
Further reading
- Reapit Foundations - building for the AppMarket - how third-party apps plug into Reapit.
- Reapit Connect - OAuth 2.0 and single sign-on - the auth flow behind the Identity section, and the SSO model we're moving toward.
- Reapit webhooks - delivery and Ed25519 signature verification - the event delivery and signing the Events section relies on.
- Reapit Elements - the component library the Design system section builds on.
- MassTransit - the partitioner (middleware filter) - how we process one property's events at a time without a distributed lock.
- vite-plugin-pwa - periodic service worker updates - the mechanism behind the "keep a long-lived tab fresh" version checks.
- Architecture Decision Records (Michael Nygard) - the lightweight format we used to pin down the lettings model before writing any code.
Craig Vincent is the Lead Programmer of Accent and has been building and releasing software for the past 20 years. He worked on the SignMaster × Reapit integration described here - the events pipeline, the two-axis lettings model, and the identity work.
A note on how this article was produced
The editorial position taken here is Craig Vincent's own, drawn from more than two decades of hands-on work in software engineering. AI tools were used to help summarise research and to shape the author's views into a working draft. Every claim was verified, and the final text was reviewed and edited by a person, who takes responsibility for it.