Compliance & GDPR

Funnel anti-spam rate-limit

Use layered form protection to reduce spam and abuse while keeping genuine patient enquiries and bookings easy to complete.

The lead funnel applies three anti-spam guards before recording a submission. All are tenant-configurable per funnel.

The three guards

1. Per-IP rate limit

  • Default: 5 submissions per 10 minutes per IP
  • Counts submissions to the same funnel from the same IP within the window
  • Reject reason: ip_rate_limited

2. Per-email rate limit

  • Default: 3 submissions per 1 hour per email
  • Counts submissions to the same funnel from the same email within the window
  • Reject reason: email_rate_limited

3. Meta-claim vs evidence

  • When the submission's utm_source is one of facebook / meta / instagram / fb / ig, or utm_medium is paid_social / cpc_social,
  • We require an fbclid to be present
  • If not, reject reason: meta_claim_without_fbclid

This catches direct bot submissions that fake Meta attribution to claim ad credit — bots typically don't replay an fbclid.

Where IP is captured

The frontend calls the funnel-submit Edge Function, which reads the client IP from these headers (in order):

  1. x-forwarded-for (first entry)
  2. cf-connecting-ip
  3. fly-client-ip
  4. x-real-ip

The IP is then passed to submit_lead as _ip_address. The RPC's guard reads it. If no IP is available, IP-based guards are skipped but email + claim-vs-evidence guards still run.

Tuning thresholds

Per funnel, on lead_funnels:

  • rate_limit_ip_window_seconds / rate_limit_ip_max
  • rate_limit_email_window_seconds / rate_limit_email_max
  • require_fbclid_for_meta_traffic (bool)

Edit via SQL or via /practice/leads → funnel detail.

Set rate_limit_*_max = 0 to disable that guard entirely.

Visibility — the rejections table

Rejected submissions are recorded in funnel_submission_rejections with:

  • Tenant + funnel
  • IP, email, UTM context, fbclid
  • Reason
  • User-agent
  • Timestamp

Read via:

select * from public.list_funnel_rejections(100);

Or via the manager UI on /practice/leads → Rejections panel.

When to tune

  • Patient complains they got blocked — check rejections for their email / IP. Genuine patients hitting a limit means thresholds are too tight — relax them.
  • Bot wave — if rejections spike, tighten thresholds temporarily.
  • Meta-attributed traffic gone — if fbclid is filtered by a recent Meta API change and legitimate ad traffic is being rejected, set require_fbclid_for_meta_traffic = false temporarily while Meta sorts it out.

What anti-spam doesn't catch

  • Sophisticated bots that replay a real fbclid and use one IP per submission — the rate limits won't fire. Add a CAPTCHA to the funnel (we don't have one yet — sized for a future slice if needed).
  • Slow-roll attacks below the threshold — by definition, this guard is for fast / repeated patterns.
  • Internal abuse — staff accidentally testing the funnel repeatedly doesn't get an exemption. Lift the threshold per funnel if your team needs to test.

Frontend UX

When a submission is rejected, the patient sees a friendly per-reason message:

  • email_rate_limited → "You've recently submitted this form. Please wait a few minutes before trying again — or check your inbox for our earlier reply."
  • ip_rate_limited → "Too many recent submissions from your network. Please wait a few minutes and try again."
  • meta_claim_without_fbclid → "We couldn't verify your visit came from our ads. Please tap our most recent ad link to retry, or contact the clinic directly."

No raw error code is shown — the patient gets actionable language.