● Roost Assured  //  Marketplace

A marketplace that moves other people’s money.

Owners post a request to have their backyard flock looked after. Background-checked sitters nearby bid on it. When a bid is accepted, the platform charges the owner, pays the sitter, and keeps a cut — which means an ordinary web app suddenly has to be right about money, identity, and what happens when the network lies to it. This walks the two integrations that carry that weight.

Stack Rails 8.1 · React 19
Payments Stripe Connect
Screening Checkr
Take rate 15%
Tests 104 passing

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.

The Roost Assured landing page: a ZIP-code search for chicken sitting or coop care, with a background-check promise and a sitter recruitment banner.
Landing · the owner side of the market

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 same landing page rendered in dark mode, warm amber on charcoal.

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.

15%Platform take
5–50miSitter travel radius
104Tests
0CI gates failing

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 Become a Sitter page, showing the five-step application process: create an account, submit the application, pay the $50 fee, complete the Checkr background check on their hosted portal, then get approved and start bidding.
Sitter recruitment · the five-step application, fee and screening spelled out up front

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.

app/services/checkr/invitation_service.rb
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"
)
  1. 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.

  2. 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.

  3. The applicant completes the check on Checkr’s portal

    Out of this system entirely. Roost Assured holds a candidate_id and an invitation_id and nothing else.

  4. 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.

  5. Approval is gated on the result

    An admin cannot approve an applicant whose report came back consider or suspended without an explicit override — and the override is written to the record.

Webhook verification
digest = OpenSSL::HMAC.hexdigest(
  "SHA256", CheckrConfig.webhook_key, payload
)
return nil unless ActiveSupport::SecurityUtils
  .secure_compare(digest, signature)
Approval gate
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 Background Check Disclosure legal page, explaining what is screened, who performs it, and the applicant's rights.

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.

$50Application fee
0SSNs stored
HMACWebhook auth
4Accepted statuses

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.

  1. Reserve, under the lock

    Re-check the guards and write a pending payment row carrying a freshly generated idempotency key. Local writes only — nothing external is touched.

  2. 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.

  3. 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.

  4. Promote the bid

    Only now does the bid become accepted and 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.

app/controllers/api/received_bids_controller.rb
@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
app/services/stripe_payments/bid_payment_service.rb
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.

3Phases per accept
4Idempotent Stripe writes
0Charges inside a transaction
15%Application fee

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.

Two failures, two answers
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_gateway

The 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.

Reconciliation by metadata
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)
end

The 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.

2Failure classes
2Webhook lookup paths
6Rack::Attack throttles
30dGeocode cache

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.

The whole deployment, render.yaml
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:start

Solid 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.

Resend, over SMTP
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 = true
Every mail ships both parts
app/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.
1Postgres instance
1Docker image
5Mailers
0Redis servers
Roost Assured Rails 8.1 · React 19Tests 104 passing

Every screen above is a capture of the running application, not a mockup. The sitters shown are fictional seed data — the app never carried real users, and no real background check or payment is represented anywhere on this page.