Back to engineering

Reliability

Stopping fake signups without punishing real owners

The Bookatu engineering team7 min read

How Bookatu keeps junk businesses off a public booking platform with layered checks that fail open, so a DNS hiccup or a Google outage never blocks a real salon owner.

Bookatu is a booking platform with a public side. Every business that signs up gets a storefront and a slug, and those pages can surface in discovery. That public surface is exactly what spammers want: a free, indexable page with outbound links. So the signup form is a target, and the cost of getting it wrong cuts both ways.

The hard part is the asymmetry. A blocked spammer just moves on. A blocked real owner, usually someone trying to set up before their next client, often never comes back. So the goal was never to build a wall. It was to add friction that bots hit and humans don't, and to make sure that when the defences misfire, they misfire in the customer's favour. Every layer below is built to fail open.

The short version

  • Cheap, layered checks instead of one heavy wall: a disposable-domain blocklist, a sanity check on the email's domain, score-based bot detection, and an email-verification gate.
  • Every layer fails open. If one of our defences has a bad day, the signup goes through; a misfire always lands in the customer's favour.
  • The real enforcement is non-blocking: a fresh org lands hidden and stays hidden until the owner verifies their email and connects Stripe.
  • New defences never punish the people already here. The gate was rolled out so that existing tenants are never locked out by it.

Layer 1: cheap rejection at the form

The cheapest check is the email domain itself. A lot of throwaway signups use temporary-inbox providers, and the catch is that a disposable inbox can still receive a verification link, so it would quietly defeat the verification gate further down. We block those domains up front.

Rather than hand-maintain a blocklist that goes stale, we build the set from the community-maintained disposable-email-domains package and add a small curated list of typo-squats on top (the gmial.com and hotmial.com class of thing). The whole thing is a pure function with no database and no network, so it slots straight into the zod schema as a refinement and is trivial to unit test.

ts
export const DISPOSABLE_EMAIL_DOMAINS: ReadonlySet<string> = new Set(
  [...disposableList, ...CURATED_EXTRA]
    .map((d) => d.trim().toLowerCase())
    .filter((d) => d.includes(".") && !d.includes("@")),
);

// in the signup schema:
email: z.string().trim().email().refine(
  (e) => !isDisposableEmailDomain(e),
  "Please use a permanent email, not a temporary/disposable one",
),

This catches the laziest bots and gives instant inline feedback. It is also the only layer that hard-blocks at the form, and it does so on a deterministic, curated list rather than a guess, which is what makes that acceptable.

Layer 2: a DNS check that refuses to be confident

The next obvious move is to confirm the email's domain can actually receive mail. This is where it would be easy to over-engineer and start blocking real people, so we made the check conservative to the point of timidity.

It only rejects a domain that doesn't resolve at all, the classic typo'd or non-existent domain. Everything else passes: a domain with proper mail records, a domain that resolves but is oddly configured (mail can still deliver there), a flaky lookup, a transient DNS error. Anything ambiguous is read as fine, and the whole check is time-boxed, so a slow resolver can never hold up a signup.

The point of this layer isn't to be the enforcement. It's to give immediate, friendly feedback on an obviously dead domain (you fat-fingered gmail.con) without ever risking a false block on a real but oddly configured one. The actual enforcement is layer 4.

Layer 3: bot scoring that treats itself as optional

Disposable lists and DNS checks don't stop a determined script using a real Gmail address. For that we run score-based bot detection on our public forms. The browser mints a token, and the server asks for a risk score before deciding what to do with the submission.

Two choices keep this from hurting real users. First, the scoring is a layer, not a wall: in an environment where it isn't configured, the forms behave exactly as they would with it, which also keeps development honest. Second, it fails open. When the check can't produce a confident answer, whatever the reason on our side or the provider's, we let the request through rather than block a real person. We only act on a score when we actually have one, and only when it is confidently bad.

Where the pass line sits is a judgment call we keep revisiting, and the scoring decision itself (including replay protection, so a token minted for one form can't be spent on another) is split into its own pure function so it's unit-tested without touching the network. The reasoning is simple. This is bot friction, not authentication. An outage on the scoring side must never stop a real customer from booking, so the captcha is the layer we most want to be skippable when it's misbehaving.

Fail open by design: this is bot friction, not authentication. A Google outage must never block a real customer from booking.

Layer 4: the part that actually does the work

The first three layers are friction. None of them stop a patient attacker, and that's fine, because the real defence is structural and non-blocking: a fresh signup can't harm us, because it isn't public yet.

A brand-new org is inserted with published set to false, a quiet coming-soon state. The owner can set everything up and preview their page, but it only goes live once they've verified their email and connected a Stripe account. Bookatu takes 0% booking commission and tenants use their own Stripe, so funds go straight to their bank, not ours, and connecting Stripe is also a strong real-business signal. Pages auto-publish the moment Connect succeeds, so most owners never even see the toggle.

On top of that, a new unverified org doesn't get dashboard access until the owner confirms their email; the guard redirects them to a verify page rather than locking the account. The confirmation link carries a signed, expiring token, so it's unguessable, tamper-evident and time-boxed. It gates publishing, not login, so a stale link can never lock anyone out. It just means not verified yet.

The gate is also deliberately non-destructive. It was rolled out so that the people already running their business on us never get caught by it, whatever the state of their inbox. That's the whole don't-punish-real-owners principle in one rule: some long-standing tenants never verified their email, and we're not about to lock them out to catch spammers who signed up later.

Why pure functions everywhere

One pattern runs through all of this: the decision logic is pulled out into pure functions with no database and no network. The disposable-domain check, the captcha scoring decision, the publish eligibility, and the access gate are all plain functions that take their inputs and return a boolean.

That's deliberate. Anti-fraud logic is exactly the code where a subtle off-by-one becomes we accidentally locked out every existing owner. Keeping the rules pure means each one is unit-tested in isolation against the cases that matter, and most of all against the cases where the right answer is to wave someone through. The side effects (the lookups, the scoring call, the redirect) live in thin wrappers around a tested core.

What's next

This setup is honest about its limits. A layered, fail-open approach lets some junk through by definition. We trade a few slipped signups for never blocking a real one. The bet is that the structural layer (hidden until verified and connected) carries the weight, so the noisier checks can stay lenient.

The next things worth doing are watching the block logs to keep tuning where the scoring line sits, and adding rate-based signals so we can react to a burst of signups from one source without raising friction for everyone else. The principle won't change: friction for bots, an open door for the owner who just wants to take bookings.

reliabilityanti-fraudsignupspamtrust-and-safety

Building on Bookatu?

Bookatu has a public REST API and webhooks. Have a look at the developer docs.

Developer docs