Skip to Content

TSFind — What a service directory taught me about APIs and data

External APIs, web services, a public database and selective filtering
October 26, 2026 by
TSFind — What a service directory taught me about APIs and data
Federico Solutions, Myrko Federico

TSFind — What a service directory taught me about APIs and data

Sector: Web / APIs / Databases / SEO

Period: April - October 2026 (paused)

Client: Own project — Trusted Services Finder (tsfind.com)

Trusted Services Finder: find who actually does good work

Context

Choosing a tradesperson or a service provider is often a blind choice: online reviews are easy to fake. With Trusted Services Finder, I wanted to build a directory of local providers resting on three pillars: listings linked to the official trade registry, structured reviews that can come with proof (invoice, photo), and a weighted trust score.

The business principle: being listed is free, a business can claim its listing for free, and it pays for tools and visibility, never for its reputation. The site was built from April to August 2026, in English, French and Spanish, with Switzerland and Mexico as first markets.

Problem

  • Filling a directory without paying for thousands of API calls or importing empty listings
  • Proving that a person really owns the business they claim
  • Exposing public data (listings, reviews) without exposing private data (proofs, contacts, subscriptions)
  • Being found by Google in three languages, with tens of thousands of pages

Solution

What was built:

  • A directory by country, city and category (57 categories), with search and an OpenStreetMap map
  • Adding a business from the free official registries (Zefix in Switzerland, DENUE in Mexico) or from Google Places
  • Claiming a listing by e-mail code (only if the domain matches the website Google knows), by WhatsApp, or by a manually checked document
  • Reviews on 6 criteria, with optional proof and an owner reply; a weighted trust score
  • An owner dashboard, Stripe subscriptions, an embeddable website badge and a QR code generator
  • A multilingual blog managed in the Directus CMS, and a full admin area (moderation, claims, export)

TSFind architecture: Next.js, self-hosted Supabase, Directus and six external APIs called with fetch()

The trust badges: official registry, verified, Premium, Sponsored, map pin

What this project taught me

1. Consuming external APIs with fetch

The site calls six external services, all with native server-side fetch: Google Places, the Swiss Zefix registry (in SPARQL), the Mexican DENUE registry, GeoNames, Cloudflare Turnstile and Meta's WhatsApp API. Each one taught me something.

  • The FieldMask is the bill. With Google Places (New), you pay according to the fields you request. I use a full mask for search and a minimal mask (phone and website) to verify a claim.
  • Get the most out of every paid call. A search returns 20 results: the user picks one, and the other 19 are imported at no extra cost.
  • A quota is counted in the database. Each paid call writes a row to a tracking table. Before calling, a per-user quota and a global monthly quota, set below Google's free tier, are checked. And tracking must never block the business call.
  • A home-made cache with a time-to-live. Business details are kept for 24 hours in the database: every re-read is free.
  • Know a public API's performance. The Swiss registry answers in 7 to 30 seconds depending on the query: heavy filtering is therefore done in code, and every user input is escaped before entering a SPARQL query.
  • Fail gracefully. Each connector returns an empty list rather than crashing the page when the API misbehaves.
  • The source of truth is not always your own database. To validate a claim, the server re-reads Google live: a listing edited just before cannot fool the check.
const res = await fetch(`${PLACES_API}:searchText`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-Goog-Api-Key": process.env.GOOGLE_PLACES_API_KEY!, // server-side only
    "X-Goog-FieldMask": "places.id,places.displayName,places.formattedAddress",
  },
  body: JSON.stringify({ textQuery: query, maxResultCount: 20 }),
});
if (!res.ok) return [];          // fail gracefully, never crash the page

2. Exposing your own web services

  • The Stripe webhook: read the request's raw body, verify the signature, reject everything else. The listing ID travels in Stripe's metadata, linking a payment to a business.
  • A minimal public API for the badge: only data already visible on the listing, only for verified listings, read-only with CORS.
  • Counting views correctly. Server-side counting also counted bots and pre-renders. The fix: a small signal sent by the browser, de-duplicated per session. Since bots do not run JavaScript, they no longer count.
export async function POST(request: Request) {
  const rawBody = Buffer.from(await request.arrayBuffer());
  const signature = request.headers.get("stripe-signature") ?? "";
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(rawBody, signature, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }
  // event.data.object.metadata.profile_id -> update the listing tier
}

3. A “public” database, selectively filtered

Supabase exposes PostgreSQL directly as a REST API. The “anon” key is public by nature: it ends up in the browser's JavaScript. PostgreSQL's Row Level Security (RLS) decides, row by row, what each role can read or write.

-- An anonymous visitor only sees published reviews
CREATE POLICY "Public read published reviews" ON reviews
  FOR SELECT TO anon
  USING (moderation_status = 'published');

-- A signed-in user also sees their own reviews awaiting moderation
CREATE POLICY "Authenticated read reviews" ON reviews
  FOR SELECT TO authenticated
  USING (moderation_status = 'published' OR reviewer_user_id = auth.uid());

I started that way, then changed my mind. The richer the data model became (claims, proofs, personal data, subscriptions), the harder it was to keep the policies right: several migrations were needed to fix Supabase's security warnings one by one. So I moved all data access to the server:

  • In the browser, the public key is now only used for authentication (sign-in, sign-up).
  • All reads and writes go through the Next.js server, which picks exactly the columns, checks ownership and masks sensitive data on display.
  • SQL privileges of the public roles are revoked, and RLS stays enabled as defence in depth. The admin key never leaves the server.
REVOKE ALL ON ALL TABLES IN SCHEMA public FROM anon;
REVOKE ALL ON ALL TABLES IN SCHEMA public FROM authenticated;

Before / after: from a public API filtered by RLS to server-only data access

The lesson: RLS is essential if the browser queries the database directly. But as soon as data becomes sensitive, routing access through a server is easier to reason about and safer. Other pitfalls: PostgREST's 1,000-row limit per response (the admin only showed a third of the listings), and functions that run with their creator's privileges, where you must know exactly who can call them.

4. Multilingual SEO

  • Every page declares its canonical URL and its language versions (hreflang en, fr, es and x-default), with Open Graph.
  • Schema.org structured data: local business, address, aggregate rating, reviews, breadcrumbs, FAQ.
  • The sitemap went through three versions: a single file, then a split version Google could not read, then back to a single file, paginated and capped below the 50,000-URL limit, with a stable last-modified date. A date that changes on every read lowers indexing priority.
  • A subtle trap: the authentication middleware marked every page “private, no-store”. A public cache had to be forced on public pages.

5. Deploying and staying within memory

  • Next.js public variables are frozen at build time: adding one means rebuilding the Docker image.
  • Anything not in the repository will eventually be overwritten. Memory limits only existed on the server; a deployment erased them, and the app saturated a shared machine. Since then the limit is versioned, with the Node heap set to about 75% of the cap, to get a readable error rather than a silent kill.
  • A single deployment script, never by hand, and never architecture and code in the same deployment.
  • Return on investment before technology. Automatic OCR checking of documents was ruled out: hours of development and a security risk, to save less than an hour of manual work a month.
mem_limit: 1g
memswap_limit: 1g                          # same as mem_limit: no swap
environment:
  - NODE_OPTIONS=--max-old-space-size=768  # ~75% of the cap

Result

Deliverable:

  • More than 35,000 businesses indexed across 50 countries and 28,000 cities, 57 categories, two official registries integrated
  • A complete site in three languages: claims, reviews, subscriptions, blog, admin
  • About 290 commits and 57 SQL migrations in four months of development

And an honest assessment: the supply was there, the demand did not follow. At the end of August, after 35,000 listings, there were only 5 claimed listings and 3 reviews, with no paid subscription; when it was paused, the site received about 35 visitors a month. A directory needs visitors, active businesses and trust at the same time: the differentiation (official registry, proof) was real, but it does not solve distribution. The site has been paused since October 2026; the data is kept.

This project demonstrates:

  • Command of external APIs: costs, quotas, caching, errors, sources of truth
  • Designing safe web services: signed webhooks, minimal public API, reliable counting
  • Data security: RLS, revoked privileges, server-only access
  • Large-scale multilingual technical SEO, and the clarity to pause a project when the market does not respond

Need infrastructure or AI agents for your business?

Self-hosted servers, Odoo ERP, AI agents, automation: I review your stack and propose a sovereign, maintainable solution.

Free discovery callContact me

Tags: lab, Next.js, Supabase, PostgreSQL, Row Level Security, API, fetch, webhooks, Stripe, SEO, Docker

DataTerra — Territorial intelligence, from satellite to drone
Geospatial reports, drone services and software for operators