The marketplace
Two sides, and a cut of what passes between them.
An owner enters a ZIP and says which days they need covered. Every active sitter whose travel radius reaches them sees the request and can bid on it. The owner accepts one. That single click is where the money moves, and it is the only click in the app that can go wrong expensively.

Matching runs on distance, not on a city name
Every sitter carries a travel radius. A SQL bounding box narrows candidates, then an exact Haversine check decides — the box is over-inclusive at its corners, so it never gets the final say.
The owner’s address stays private until it has to not be
Sitters browsing requests see a ZIP centroid on a map. Phone number and street address are released only once a bid is accepted, in the serializer, not by convention.
Bids go stale on purpose
If the owner edits the substance of a request after a sitter has bid, that bid is flagged stale and can’t be accepted until the sitter resubmits. Nobody gets charged for a job that changed under them.
Eligibility is enforced on the write
A sitter can only bid on someone their radius actually reaches. That rule lives in one predicate the index query and the create action both consult — not in the query that happens to render the page.
Live job tracking
Once a job starts, the sitter’s checklist, ETA and photos stream to the owner over Action Cable. A job can’t be marked complete until every task is ticked and at least one photo is attached.
Blocking is mutual
It doesn’t matter who blocked whom — after that, neither side can bid, message, or match with the other again.

The theme resolves before first paint
Dark is the default. The choice is read from localStorage by a small inline script in the document head, which sets a data-theme attribute before React ever mounts — otherwise a light-mode visitor gets a dark flash on every single page load.
That script is the one exception to a Content Security Policy that is otherwise script-src 'self' with named third-party origins rather than a blanket https:. It gets a per-response nonce, so it runs without opening the policy to inline script generally.
Vetting · Checkr
Never hold the data you don’t want to be responsible for.
This app sends strangers to people’s homes, so sitters are screened before they can take a job. The screening is real — a Checkr criminal background report — and the $50 application fee exists to pay for it. The design constraint that shaped everything else: Roost Assured never sees an SSN or a date of birth.

The applicant never types identifying information into this app. Roost Assured creates a candidate and an invitation on Checkr, and Checkr emails the applicant a link to its own hosted portal, where consent, SSN and date of birth are collected. What comes back here is a status string.
candidate = client.post("candidates", {
email: sitter_application.email_address,
first_name: first_name,
last_name: last_name,
zipcode: sitter_application.zip_code
})
invitation = client.post("invitations", {
candidate_id: candidate["id"],
package: CheckrConfig::PACKAGE
})
sitter_application.update!(
checkr_candidate_id: candidate["id"],
checkr_invitation_id: invitation["id"],
background_check_status: "invited"
)Application submitted, fee charged
An on-session Stripe charge for $50, restricted to card payment methods so no redirect-based method can demand a return URL mid-flow. A prior paid-but-unsaved attempt is reused rather than billed twice.
Candidate and invitation created
Two calls to Checkr. Failure here is caught and logged rather than raising — the application is already saved and paid for, and losing it because a third party had a bad minute would be the worse outcome.
The applicant completes the check on Checkr’s portal
Out of this system entirely. Roost Assured holds a
candidate_idand aninvitation_idand nothing else.Checkr calls back, signed
Every webhook is HMAC-verified before it is believed, and every event id is recorded so a redelivery can’t be processed twice.
Approval is gated on the result
An admin cannot approve an applicant whose report came back
considerorsuspendedwithout an explicit override — and the override is written to the record.
digest = OpenSSL::HMAC.hexdigest( "SHA256", CheckrConfig.webhook_key, payload ) return nil unless ActiveSupport::SecurityUtils .secure_compare(digest, signature)
def approvable?(override: false) background_check_cleared? || override end
The gate lives in the model
approvable? and the reason a decision was blocked are defined on SitterApplication, not in the admin controller. The rule travels with the record, so no future code path can approve around it.
Overrides leave a trace
Approving despite a flagged report is allowed — a dispute can be legitimate — but it sets a boolean on the application. “Who approved a sitter whose check wasn’t clear” has to be answerable later.
Statuses are allow-listed
The report handler only accepts clear, consider, suspended and dispute. An unrecognised status is ignored rather than written through into a field the approval gate reads.
Consent is validated, not implied
background_check_consent is an acceptance validation on the application itself — an applicant cannot reach the screening step without it, and the record carries proof that they agreed.
An applicant is never charged twice
A succeeded fee that isn’t yet attached to an application is picked up by the next attempt rather than billing the non-refundable $50 again — and the charge itself carries an idempotency key scoped to the card used.
The whole integration is optional
Without an API key configured, CheckrConfig.configured? is false and the invitation step no-ops. Local development and CI never reach out to a real screening provider.

The disclosure is a page, not a checkbox
Consent to a background check is a legal act with its own requirements, so it gets a real page explaining who performs the screening, what it covers, and what the applicant can do about a result they dispute.
It is linked from the footer of every page and from the application flow itself, so the terms an applicant is agreeing to are reachable before they agree rather than buried in an acceptance checkbox.
Money · Stripe Connect
The charge sits outside the transaction, deliberately.
Accepting a bid charges the owner’s stored card, routes the money to the sitter’s Connect account, and keeps 15% as an application fee. A charge is an irreversible external side effect, and a database transaction is a thing that can be undone — so the two are never allowed to overlap. The accept path is built in three phases to guarantee it.
Reserve, under the lock
Re-check the guards and write a
pendingpayment row carrying a freshly generated idempotency key. Local writes only — nothing external is touched.Commit
The transaction closes here. From this moment the reservation is durable, so the already-paid guard is backed by a committed row before a single cent moves.
Charge, outside any transaction
One
PaymentIntent, confirmed off-session, with a destination transfer and an application fee. Nothing can roll it away, because there is nothing to roll back into.Promote the bid
Only now does the bid become
acceptedand the competing bids get rejected. The bid is never in a state the money doesn’t back.
The ordering is the whole design. A payment provider has no way of knowing that a database transaction rolled back, so the reservation is committed before any money moves — which means the guard against charging an owner twice is backed by a durable row rather than one that could disappear from underneath it.
@bid.with_lock do rejection = guard_reasons_for(@bid) payment = BidPaymentService.new.reserve!(@bid) if rejection.nil? end # ── committed ───────────────────────────────────────────── BidPaymentService.new.charge!(payment) @bid.with_lock do other_bids.update_all(status: "rejected") @bid.update!(status: "accepted") @bid.seed_job_tasks! end
intent = ::Stripe::PaymentIntent.create(
{
amount: (payment.amount * 100).round,
currency: "usd",
customer: owner.stripe_customer_id,
payment_method: owner.default_payment_method_id,
confirm: true,
off_session: true,
application_fee_amount: (payment.application_fee_amount * 100).round,
transfer_data: { destination: sitter.stripe_account_id },
# lets the webhook find this row even if the response never lands
metadata: { payment_id: payment.id, bid_id: bid.id }
},
# collapses a retry into the original charge instead of billing twice
idempotency_key: "payment-#{payment.idempotency_key}"
)Destination charges, not transfers
The owner is charged, the sitter’s Express account is the transfer destination, and the platform’s 15% is an application_fee_amount on the same intent. One object to reason about, one object to refund.
Every Stripe write carries an idempotency key
The bid charge, the $50 application fee, refunds, and Connect account creation. A timeout where the request actually succeeded is the normal case this protects against, not an exotic one.
The key is the reservation’s, not the bid’s
Scoping the key to the reservation rather than the bid is what lets a genuine second attempt through after a declined card, while still collapsing an accidental retry of the same attempt into one charge.
Money is decimal all the way down
Amounts are decimal columns, never floats, and are converted to integer cents at the boundary with Stripe. A waived commission is a legitimate $0 fee, so the validation admits it rather than treating zero as an error.
Card state is mirrored locally
“Can this owner be charged?” is a column read, not a live Customer.retrieve on the account page and again mid-checkout. The customer.updated webhook keeps it honest when a card changes on Stripe’s side.
The isolation is tested, not assumed
A test stubs the step after the charge to raise, then asserts the payment row survived the exception. A guarantee this quiet needs a test that would notice the day it stopped holding.
When the wire lies
A timeout is not a failure. It’s a question.
Most error handling collapses everything that isn’t success into one bucket. On a payment path that is exactly wrong: a declined card and a connection reset call for opposite responses, and guessing costs real money in one direction or a stuck customer in the other.
rescue ::Stripe::CardError => e
# Definitive: no charge was created. Release the reservation so the
# owner can try a different card.
payment&.update!(status: "failed")
render json: { errors: [ e.message ] }, status: :unprocessable_entity
rescue ::Stripe::StripeError => e
# Ambiguous — a timeout, a reset. The charge may well have landed.
# Leave it pending: the guard stays armed so a retry can't bill twice,
# and the webhook settles it either way.
render json: { errors: [ "We couldn't confirm that payment." ] },
status: :bad_gatewayThe webhook is what closes the loop — and it has its own race. Stripe frequently delivers payment_intent.succeeded before the API response carrying the intent id has been written down. Looking the row up by that id alone loses; and because the event was already recorded as processed, the retry is dropped too, stranding the payment in pending forever.
def find_payment(intent)
by_metadata = intent.metadata&.[]("payment_id")
(by_metadata && Payment.find_by(id: by_metadata)) ||
Payment.find_by(stripe_payment_intent_id: intent.id)
endThe payment carries its own id into Stripe
Stamping payment_id into the intent’s metadata gives the webhook something stable to match on that exists before the charge is made, not after it returns.
Replay protection is a unique index
Every Stripe and Checkr event id is inserted into its own table before handling. A duplicate delivery hits the constraint and returns early — not a cache, not a lock, a row.
Signatures verified against the raw body
request.raw_post, not request.body.read — params parsing has already consumed the stream, and the signature covers the original bytes.
A late decline reopens the bid
Off-session charges can fail asynchronously, after the bid was already promoted. When that webhook lands, the bid goes back on the board so the owner can pick another sitter or another card.
Rate limits before the expensive work
Rack::Attack throttles by IP at the edge, and the sensitive actions throttle again per user inside the app. Login, signup, password reset, bidding, and messaging each carry their own budget.
Third-party calls run in jobs
Geocoding and the sitter-alert fan-out are background jobs, never request-cycle callbacks, so a slow upstream can’t occupy a web thread. Geocode results are cached for 30 days, misses included.
Shipping it
One database, one image, three processes.
A marketplace this size does not need a queue server, a cache server, a pub/sub server and a mail service to go with its web server. Rails 8 ships database-backed adapters for the first three, so the whole thing runs on one managed Postgres and a single Docker image, described by one file Render reads on its own.
databases:
- name: roost-assured-db
plan: basic-256mb
services:
- type: web
name: roost-assured-web
runtime: docker
healthCheckPath: /up
disk: # uploads survive a deploy
mountPath: /var/data
sizeGB: 1
- type: worker
name: roost-assured-worker
runtime: docker # same image as the web service
dockerCommand: bin/rails solid_queue:startSolid Queue, Cache and Cable on the primary database
Rails 8 defaults these onto separate databases. At this scale that meant provisioning three more Postgres instances to hold a job table and a cache table — so they were consolidated back onto the primary in a real migration.
The worker is the same image
One multi-stage Dockerfile builds gems, runs npm ci, compiles the Vite bundle, then throws the toolchain away. The web service and the background worker are the same artifact started with different commands.
Assets precompile without secrets
SECRET_KEY_BASE_DUMMY=1 lets the image build without RAILS_MASTER_KEY, so the master key never has to be present at build time — only at boot.
Storage is sized to the deployment
Uploads go to a persistent Render disk rather than object storage, which suits a single-instance service and keeps a dependency out of the stack. The tradeoff is written down next to the config, and storage.yml carries the S3 block for the day it changes.
Housekeeping is a recurring job
Solid Queue’s own scheduler clears finished jobs hourly, in batches with a sleep between them, so table maintenance never becomes a thing someone has to remember.
The SPA is served same-origin
vite_rails builds the React app into the Rails asset pipeline and a catch-all route hands every non-API path to the client router. No second host, no CORS in production, and the session cookie stays first-party.
Mail that renders anywhere
Five mailers carry the transactional traffic: the waitlist welcome, password resets, the new-request receipt, the nearby-sitter alert, and the sitter-is-on-the-way notice. Delivery goes through Resend’s SMTP relay rather than an API client, which keeps it ordinary Action Mailer — the provider is four lines of configuration and nothing in the app knows its name.
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
address: "smtp.resend.com",
port: 587,
user_name: "resend",
password: resend_api_key,
authentication: :plain,
enable_starttls_auto: true
}
# fail loudly rather than dropping mail silently
config.action_mailer.raise_delivery_errors = trueapp/views/sitting_request_mailer/ receipt.html.erb receipt.text.erb new_request_alert.html.erb new_request_alert.text.erb app/views/application_mailer/ _button.html.erb # table-based CTA
HTML and plain text, every time
Multipart isn’t decoration — a text part is what keeps a transactional message out of spam filters and readable in clients that refuse HTML. Every template exists twice.
Buttons are tables
A shared partial renders call-to-action buttons the way mail clients still require: nested tables with inline styles, because Outlook does not have a flexbox.
Development never sends
Outgoing mail is written to tmp/mails locally, so the whole notification flow can be exercised without an SMTP account or a risk of mailing a real address.
Sending happens in jobs
Every mailer is invoked with deliver_later, so a slow relay can’t hold a request open — and the alert fan-out to nearby sitters is itself a job rather than an after-save callback.
Notable dependencies
Sixteen runtime gems, most of them Rails’ own. The ones worth explaining:
- stripe
- The official SDK, pinned to v13. Connect Express accounts for sitters, destination charges with an application fee, and a pinned API version so a Stripe-side upgrade can never change behaviour without a deliberate bump.
- faraday
- Checkr publishes no official Ruby gem, so its client is about twenty lines of Faraday with basic auth and JSON middleware. A thin wrapper beats an unmaintained third-party SDK for two endpoints.
- bcrypt
- Authentication is Rails’ own —
has_secure_password, signed cookies, and a Session model with inactivity and absolute timeouts. Devise would have been more code to configure than to write. - solid_queue
solid_cache
solid_cable - Jobs, cache and websockets on Postgres. No Redis, which at this size is a service to provision, monitor and pay for in exchange for nothing.
- rack-attack
- Edge throttling by IP for login, signup, password reset, the admin surface and the webhook endpoints — before a request reaches anything expensive.
- vite_rails
- React 19 built by Vite and served by Rails from one origin. Replaces the Sprockets/Webpacker split with one build that the Docker image runs at precompile time.
- image_processing
- Active Storage variants for sitter profile photos and the job-completion photos, backed by libvips in the image rather than the heavier ImageMagick.
- thruster
- Sits in front of Puma for HTTP caching, compression and X-Sendfile, so the app can serve its own static assets without a separate proxy tier.
- brakeman
bundler-audit - Static analysis and a dependency CVE check, both gating CI alongside RuboCop, ESLint and the test suite. Five gates, all green.