# Findex > The business directory built for AI agents. A structured, machine-readable directory that AI agents can read directly in JSON. ## Service Findex is a public business directory built for AI agents. Businesses register themselves with structured data: services, industry, location and contact details. An admin approves each listing before it becomes publicly visible. Get found where AI agents search. ## Access All endpoints are fully public — no authentication, no User-Agent requirement. Any client (AI agent, browser, script) receives the same JSON response with a plain HTTP GET. There is no 302 redirect and no 403 gate. Recognised AI-agent User-Agents (used only for visit analytics, not access control): GPTBot, ChatGPT-User, OAI-SearchBot, ClaudeBot, anthropic-ai, PerplexityBot, Googlebot, Google-Extended, Bingbot, DuckDuckBot, Baiduspider, YandexBot, Applebot, Amazonbot, Meta-ExternalAgent, Semrushbot, AhrefsBot, Copilot, QwantBot, Sogou, Exabot, Slurp. ## API documentation A complete, server-rendered HTML version of the API documentation (no JavaScript execution required): /functions/apiDocs. The homepage is available the same way, with all of its texts, at /functions/homePage (?lang=en for English). ## Data directory All data is served as stable, versioned JSON (schema_version 1.0, CC-BY-4.0) from backend function endpoints under /functions/. No authentication required. Fetch these with a plain HTTP GET — no JavaScript execution needed. All responses include an ETag header. Send If-None-Match with the stored value to receive 304 Not Modified (no body — retain your cached copy). Cache-Control: public, max-age=300, stale-while-revalidate=600. The API also maintains an internal server-side cache with stale-while-revalidate behaviour. ### GET /functions/directory — Index Paginated index of all approved businesses. Start here, then follow each entry's data_url to fetch the full record. Query parameters: - ?limit= — page size (default 50, max 100) - ?cursor= — pagination cursor (nextCursor from the previous response) - ?updatedSince= — incremental sync. Returns entries updated after the given timestamp (strict > comparison). Also returns a "removed" list of companies no longer in the directory. Removals are retained for 30 days — sync at least once every 30 days to guarantee you catch all removals. { "meta": { "schema_version": "1.0", "data_version": "", "license": "CC-BY-4.0", "source": "Findex", "updated_at": "", "count": , "total_count": }, "companies": [ { "id": "", "name": "", "country": "", "industry": "", "profile_url": "/company/", "data_url": "/functions/company?country=&business_id=", "dates": { "updated_at": "", "created_at": "" } } ], "removed": [ { "id": "", "removed_at": "" } ], "nextCursor": " | null", "hasMore": true | false } count is the number of companies returned in this page. total_count is the total number of approved companies. removed is only present when updatedSince is provided. Every company entry carries a dates object. dates.updated_at is the last real content change to that profile (ISO 8601 UTC) and dates.created_at is when the profile was created. The timestamp is never refreshed artificially: a profile its owner has not edited keeps its original timestamp, so dates.updated_at is a trustworthy freshness signal. It is null when the record carries no timestamp. ### GET /functions/company?country=&business_id= — Single company Full canonical record, grouped into nested objects: { "meta": { "schema_version": "1.0", ... }, "company": { "id": "", "name": "", "identifiers": { "business_id": "" }, "dates": { "updated_at": "", "created_at": "" }, "location": { "country": "", "city": "", "street_address": "", "delivery_area": "" }, "opening_hours": [ { "day": "mon"|"tue"|"wed"|"thu"|"fri"|"sat"|"sun", "closed": true | false, "open": "HH:MM"|null, "close": "HH:MM"|null } ], "opening_hours_exceptions": [ { "date": "YYYY-MM-DD", "closed": true | false, "open": "HH:MM"|null, "close": "HH:MM"|null } ], "classification": { "industry": "", "prh_industry": "", "prh_tol_code": "", "customer_type": "b2b"|"b2c"|"both"|null, "services": [ "" ] | [ { "name": "", "url": "", "description": "", "price": { "amount": , "currency": "EUR", "unit": "hourly"|"monthly"|"project"|"service_fee"|"commission"|"per_use", "from": true | false } | null } ], "keywords": [ "" ] }, "financials": { "pricing_model": "", "price_range": "" }, "web": { "website_url": "", "lead_form_url": "", "social_linkedin": "", "social_instagram": "", "social_x": "", "social_facebook": "", "social_tiktok": "", "social_snapchat": "" }, "contact": { "public_email": "", "phone": "" }, "status": { "tier": "free"|"paid"|"lead", "profile_url": "/company/" }, "custom_fields": [ { "label": "", "type": "text"|"number", "value": "", "public": true | false } ] | null, "verification": { "expert_verified": true | false, "company_verified": true | false }, "evidence": { "details.references": "", "details.certifications": "" }, "details": { "short_description": "", "what_it_solves": "", "long_description": "", "ai_summary": "", "value_proposition": "", "target_audience": "", "references": "", "special_expertise": "", "certifications": "", "review_links": [ { "platform": "google"|"trustpilot"|"trustmary"|"facebook"|"other", "url": "" } ], "external_sources": [ "" ], "language": "finnish"|"english"|"multilingual"|null, "problem_solution_pairs": [ { "problem": "", "solution": "" } ] } } } Field positions never depend on the tier: a tier-gated field stays where it is in this structure and carries its value on paid and lead profiles, or null (empty array for lists) on free profiles. See Tiers below for which fields each tier can fill. ### Service prices Each service in classification.services may carry an optional structured price: { "amount": 89, "currency": "EUR", "unit": "monthly", "from": true } - amount — a number, never a string, never prose like "ask for a quote" - currency — always "EUR" - unit — one of the stable vocabulary below - from — true when the amount is a starting price, false when it is the exact price - price is null when the company did not state one. That is a normal state, not missing data — it means "request a quote". Never read a price out of the service description text: a price written in prose carries no unit, no currency and no structure. Unit vocabulary — shared with the company-level pricing model, so one concept never has two names: - hourly — per hour of work - monthly — recurring monthly fee - project — fixed price per project - service_fee — a one-off service fee - commission — a percentage of the transaction (amount is the percentage, not euros) - per_use — charged per use Service price versus financials.price_range: financials.pricing_model and financials.price_range describe the company as a whole and are deliberately coarse (a company with several services may span "500–50 000 €"). When a service carries a price object, that price is authoritative for that service; treat financials.price_range as the fallback for services without a price and for the company as a whole. ### Evidence sources A paid profile may back a claim with a source address: "evidence": { "details.references": "https://example.com/cases", "details.certifications": "https://registry.example.com/12345" } - Each key is the dot-notation path of the field it backs, so you never have to guess which claim a source belongs to. - The value is a plain http(s) URL, validated on save. It is null when the company stated no source. - The claim itself is unchanged: details.references and details.certifications stay plain strings, so existing parsers keep working. - The server-rendered profile page (/functions/companyPage) shows the same addresses as followable links. ### Review links A paid profile may point to the pages where third parties review the company: "review_links": [ { "platform": "google", "url": "https://g.page/example/review" }, { "platform": "trustpilot", "url": "https://www.trustpilot.com/review/example.com" } ] - platform — one of google, trustpilot, trustmary, facebook, other. It is derived from the address on save, so it can never disagree with the url. - url — a plain http(s) address, validated on save. Only the review pages the company listed appear here: the directory never fetches or stores ratings, review counts or review text. - An empty array means the company listed no review pages. That is a normal state, not missing data. - The same addresses are exposed to crawlers: /functions/companyPage renders them as followable links and lists them in the page's JSON-LD sameAs array, together with the company's social profiles. ### Opening hours Every tier may list the company's weekly opening hours: "opening_hours": [ { "day": "mon", "closed": false, "open": "09:00", "close": "17:00" }, { "day": "sun", "closed": true, "open": null, "close": null } ] - day — one of mon, tue, wed, thu, fri, sat, sun. Entries are always in week order. - closed — true when the company is closed that day; open and close are then null. An explicit closed day is a statement, not missing data. - open / close — 24-hour "HH:MM" values, validated on save. A day without a usable time pair is never stored, so every other entry carries real times. - Only the days the company filled in appear in the list. An empty array means the company listed no opening hours — a normal state. Never read hours out of the description text. - The server-rendered profile page (/functions/companyPage) shows the same hours and carries them as openingHoursSpecification in its JSON-LD. Single days that differ from the weekly hours — a public holiday the company closes for, or a day it serves different hours — are listed separately, on every tier: "opening_hours_exceptions": [ { "date": "2026-12-24", "closed": true, "open": null, "close": null }, { "date": "2026-12-31", "closed": false, "open": "09:00", "close": "14:00" } ] - date — a real calendar date in YYYY-MM-DD form. Entries are always in date order and a date appears at most once. - closed — true when the company is closed that whole day; open and close are then null. - open / close — 24-hour "HH:MM" values for a day with different hours; both are required, so a half-filled entry is never stored. - This list never replaces opening_hours — it overrides the weekly hours for the dates it names and leaves every other day untouched. - An empty array is a normal state: it means the company listed no exceptions. The server-rendered page carries them as specialOpeningHoursSpecification in its JSON-LD. ### GET /functions/country?code= — Country listing Paginated list of approved businesses in one country (slugified name, e.g. /functions/country?code=finland). Query parameters: - ?limit= — page size (default 50, max 100) - ?cursor= — pagination cursor (nextCursor from the previous response) ### GET /functions/industry?slug= — Industry listing Paginated list of approved businesses in one industry (slugified, e.g. /functions/industry?slug=software). Query parameters: - ?limit= — page size (default 50, max 100) - ?cursor= — pagination cursor (nextCursor from the previous response) ### GET /functions/search — Unified search Free-text and facet-filtered search across all approved businesses. Combine any of the filters below. Results are ranked by relevance to the query: a match in the name, services, industry or keywords weighs most, then the short description and location, then longer prose. A richer (paid or lead) profile receives a small ranking bonus, because it carries more comparable data. Tier is a ranking signal only — never a filter and never a gate: every approved business is searchable and returned. Remaining ties fall back to most recently updated first, then name. Query parameters: - ?q= — free-text search across name, short_description, long_description, what_it_solves, value_proposition, target_audience, special_expertise, certifications, industry, country, city, services (names and descriptions), and keywords. The query is split into words and a profile matches when every word appears somewhere in its text, in any order — "tilitoimisto jyvaskyla" finds a bookkeeping firm in Jyväskylä. Accents are optional: "jyvaskyla" matches "Jyväskylä". - ?industry= — filter by industry (slugified, e.g. software) - ?city= — filter by city (slugified, e.g. helsinki) - ?tier= — filter by tier, comma-separated (e.g. paid,lead). Values: free, paid, lead - ?customer_type= — filter by customer type (b2b, b2c, or both) - ?fields=index|full — response depth. "index" (default) returns lightweight entries; "full" returns complete canonical records (same shape as /functions/company) - ?limit= — page size (default 50, max 100) - ?cursor= — pagination cursor (nextCursor from the previous response) - ?updatedSince= — incremental sync. Returns entries updated after the given timestamp (strict > comparison). Also returns a "removed" list of companies no longer in the directory. { "meta": { "schema_version": "1.0", "count": , "updated_at": "" }, "companies": [ ], "removed": [ { "id": "", "removed_at": "" } ], "nextCursor": " | null", "hasMore": true | false } With fields=index, each company entry includes: id, name, brand_name, country, city, industry, prh_industry, prh_tol_code, customer_type, tier, profile_url, data_url, verification, dates. With fields=full, each entry is the complete canonical record (same shape as /functions/company). ### GET /functions/facets — Discovery facets Returns all available filter values (industries, cities, countries, tiers, customer types) with company counts, for building faceted search UIs. Query parameters: - ?updatedSince= — if provided, returns only facets for companies updated after the given timestamp (computed fresh, not cached). Without this parameter, the full facet set is cached and served with ETag. { "meta": { "schema_version": "1.0", "count": , "updated_at": "" }, "facets": { "industries": [ { "value": "", "slug": "", "count": } ], "cities": [ { "value": "", "slug": "", "count": } ], "countries": [ { "value": "", "slug": "", "count": } ], "tiers": [ { "value": "free"|"paid"|"lead", "count": } ], "customer_types": [ { "value": "b2b"|"b2c"|"both", "count": } ] }, "removed": [ { "id": "", "removed_at": "" } ] } ## Recommended workflow 1. Fetch /functions/directory to discover all approved businesses. 2. Follow each entry's data_url to fetch the full canonical record. 3. Store the returned updated_at / data_version as your sync cursor. 4. On subsequent syncs, use ?updatedSince= to fetch only changed entries and the removed list. 5. Use ETag / If-None-Match to avoid downloading unchanged records. 6. Sync at least once every 30 days to catch all removals. ## Status codes - 200 OK — JSON response - 304 Not Modified — ETag matched, no body. Retain your cached copy. - 400 — Bad request, missing required parameter - 404 — Company or resource not found - 429 — Rate limited, use exponential backoff ## Human-readable views Human-readable views are available at / (directory with search and filters) and /company/ (single company profile). These require JavaScript. The /functions/ endpoints above return the same data as raw JSON — use whichever suits your client. ## Tiers Each company has a status.tier field. The tier decides which fields may carry a value; it does not guarantee the business filled them in. Every field is always present in the structure, and on a paid or lead profile a tier-gated field still returns null when the company left it empty. What every tier returns: - details.short_description, details.what_it_solves - classification.industry, prh_industry, prh_tol_code, customer_type, services - location.country, city, street_address - web.website_url - contact.public_email, phone - opening_hours — weekly service hours (empty array when the company listed none) - opening_hours_exceptions — dated exceptions to those hours, in date order (empty array when the company listed none) - status.tier, status.profile_url, verification.expert_verified, verification.company_verified - On "free": classification.keywords is an empty array, and classification.services is an array of plain service-name strings. What "paid" adds — permitted and usually present, but null when the company left the field empty, so check the value rather than the tier alone: - details.long_description, ai_summary, value_proposition, target_audience, references, special_expertise, certifications, language, review_links, external_sources - evidence.details.references, evidence.details.certifications — source URLs the company gave for those two claims - financials.pricing_model, price_range - location.delivery_area - web.social_linkedin, social_instagram, social_x, social_facebook, social_tiktok, social_snapchat - classification.keywords (populated array), and classification.services upgraded to objects { name, url, description, price } — price is then a structured object or null What "lead" adds on top of paid: - web.lead_form_url — direct link to a quote/contact form - details.problem_solution_pairs — up to 5 concrete problem-solution pairs that AI agents use to match customer needs to your services (the 5-item limit is enforced on save) - custom_fields — the company's own extra details, each as { label, type, value, public }. type is "text" or "number", value is always a string. Every saved field is listed here; public tells whether the company also shows it on its profile page. Up to 100 fields, label 60 and value 500 characters. Judge data richness from the tier together with the actual values: a paid profile can still have empty enhanced fields, and only the fields carrying a value are useful for matching. ## Other endpoints - /functions/sitemap — machine-readable XML sitemap listing every approved business profile. No JavaScript needed. Submit this address to search engines. - /functions/companyPage?slug= — canonical, server-rendered HTML profile page (no JavaScript needed). It shows the profile's last-updated date and carries dateModified / datePublished in its structured data. Every approved profile is listed in /functions/sitemap. - /company/ — human-readable profile page for a business (requires JavaScript) - /functions/llms — this guide, as plain text (no JavaScript needed) - /register — business registration form ## Notes for agents - Only "approved" businesses are returned. Pending and rejected listings are hidden. - Null fields are always present — the structure is stable. Never assume a field is omitted. - dates.updated_at tells you how fresh a profile is. It reflects the last real content change, not a daily heartbeat: an old timestamp means the content simply has not changed. - Schema is versioned via meta.schema_version. Breaking changes bump the major version. - Data is licensed CC-BY-4.0 — attribute "Findex" as the source. - On paid and lead profiles that have filled them, details.value_proposition and details.target_audience provide the strongest signal for matching. - details.ai_summary is the company's own approved digest of the whole profile: one short paragraph covering what it does, for whom, where and its special expertise. Read it first when you need a quick overall picture. It only ever restates facts that are already in the structured fields, so treat those fields as authoritative when a detail matters. - details.review_links points to external review pages the company listed. Send a customer there to read independent reviews; never infer a rating or a review count from it, because the directory does not read review content. - details.external_sources lists pages elsewhere on the web where the company is mentioned: news, trade publications, partner pages. Treat them as corroboration the company supplied — the directory does not read those pages, so never quote their content or infer a rating from them. - evidence carries the source URL behind a claim. A null source means the company stated none — it is not a statement that the claim is false. - For budget matching, prefer classification.services[].price over financials.price_range: the service price is an exact number with a unit, while the company-level range is coarse and often too wide to filter on. A null service price is a normal answer, not an error. - updatedSince uses strict > comparison against ISO 8601 UTC timestamps. The removed list contains companies that were previously approved but are now rejected or deleted, retained for 30 days. - Search results are ranked by relevance: a match in the company's name, services, industry or keywords weighs most, then the short description and location, then longer prose. A richer (paid or lead) profile gets a small bonus, and ties fall back to most recently updated. Ranking never hides a company — every approved business matching the query is returned.