Build log

What we shipped

Every change to GoLeadX, written down as it happens — new sources, verification improvements, fixes, and the reasoning behind each one. This page is generated from the codebase itself on every deploy, so it cannot drift from what actually shipped.

59 changes7 daysLast update Monday, 24 August 2026

Monday, 24 August 20262 changes

  1. Stop the sitemap telling Google everything changed an hour ago

    b8caf98

    The sitemap stamped `new Date()` on almost every URL it listed, and the route revalidates hourly — so 136 of 143 entries announced a fresh modification time every hour, forever. The privacy policy claimed to have changed at 11:10 this morning, and again at 12:10, and so on since launch.

  2. Give every trade on the landing page a page of its own

    aec636b

    The chip list under "every other category that lists itself in public" was forty high-intent phrases sitting in plain <li> elements. The only query that section could rank for was the one its own H2 asked, and a visitor who found their trade in the list had nowhere to go next.

    • The sticky header rule was written as a bare `header` element selector, so it
    • API moved into the top nav. It had one link on the whole site, in the footer,
    • Brand alternateNames widened. The first Search Console impressions are

Sunday, 23 August 20267 changes

  1. Give the commercial queries a page, and stop the site contradicting itself

    51046f0

    The site could rank for questions and for the brand, and for nothing in between. The landing page aims at "lead generation software", which a young domain does not win, and below it the next thing was the blog, which answers questions rather than naming a tool. The queries in the gap are the ones that convert — "google maps scraper", "instagram email scraper", "shopify store scraper" — and they are winnable.

  2. Say what people search for, and give sign-up a page that can rank

    cf3f5f6

    Two problems, one cause: the site described itself in its own words rather than in the words anyone types into a search box.

  3. Stop the sticky header freezing sections on screen

    d999043

    The bar was 92% transparent with backdrop-filter: blur(14px). A sticky element that filters its own backdrop forces the compositor to re-read the pixels behind it on every scroll frame, and when that invalidation is missed — routinely, on Chrome/Windows with GPU rasterisation — the stale tiles stay painted. The visible result is a band of the page that looks frozen while everything else scrolls underneath it. Repo…

  4. Show the product working, and name the markets it covers

    61eb587

    The landing page described the tool three times and demonstrated it once, at the bottom. This rebuilds the middle of it.

  5. Stop telling customers which server credential is missing

    4d93005

    The follower and viral modes are switched off until their backends are in place, and the form said so by naming APIFY_TOKEN — in three notices, in the error under the button, and in the API response behind it.

  6. Page the search log, and stop it counting only what fits on screen

    c7ea1c9

    The log opened showing ten searches and a "searches run" total of ten, however many the account had actually run. Three separate causes:

    • The dashboard was seeded with ten rows and only refreshed the list when
    • The drawer summed the rows it had loaded and printed that under a
    • The list was capped with no way to ask for more, so past the ceiling a
  7. Give the site something to rank, and a way to keep writing it

    b778db1

    Two pieces of work that turned out to need each other.

Saturday, 22 August 20262 changes

  1. Record a search when it starts, and find the way back to it after a refresh

    1cbb37a

    Refreshing the page mid-search lost it. The work carried on server-side and the credits were already spent, but nothing on screen led back to it: the history row was written only on completion, and the id of the job being watched lived in React state that a reload discards. The customer was left looking at an empty dashboard wondering what they had paid for.

    • Add GET /api/history so the list can refresh without a navigation
    • Keep stored results through a progress-shaped write; the update carries only
  2. Stop one launch code being farmed from a single connection

    35f9acb

    The unique index guaranteed one redemption per account, and an email address costs nothing to create — so one person could open ten accounts and take ten grants of a code meant to be a sample. Redemptions now record the address they came from, and a partial unique index makes one-per-code-per-IP a constraint rather than a check that races.

    • Disclose the stored address in the privacy policy; an IP is personal data

Friday, 21 August 20269 changes

  1. Drop "Team seats (coming soon)" from the Scale plan

    2b3abda

    A "coming soon" line in the feature list of a plan you charge $97 for is a promise with a price attached, and team seats are not close enough to be one. The slot now carries API access, which the plan actually includes today.

  2. Give the API its own section, and name the plan it needs

    13e612d

    Key management sat inside billing, which put a developer task behind a payments screen: someone wiring up n8n had to open the page about card details to find a key, and the plan requirement read as a billing rule rather than as what the key is for. It is now its own control in the dashboard toolbar, beside the log.

  3. Sell the API on the landing page

    94b685f

    The Scale plan carries API access and the page never said so, which meant the one feature that justifies the jump from $37 to $97 was invisible to anyone who had not opened the billing drawer.

  4. Let the owner account use its own API

    2c83a6c

    ADMIN_EMAILS accounts carry the entitlement plan "owner", and the API checked for "scale" alone — so the one account guaranteed to want the API for testing was the one account refused by it. Shipping an endpoint nobody has driven from a real workflow is how the first customer finds the bugs.

  5. Add a public API, on the Scale plan

    fc20b8a

    Searches can now be driven from n8n, Make or any HTTP client: POST a query to /api/v1/search, poll /api/v1/search/:id, get verified contacts back as JSON. /api/v1/me answers "is this key working", which is the first thing anyone wiring up a connection wants to know.

    • Manage keys from the billing drawer; list, create and revoke
    • Document it at /docs/api, including the n8n node sequence and the caveat
    • Add API access to the Scale card in all six languages
  6. Accept API keys however they were pasted

    1fbae8d

    A key wrapped in quotes is present, non-empty, and refused by every provider with the same 401 an expired key gives. So the log said "the key was rejected — check it is active at serper.dev", which sent us to a dashboard where the key was perfectly fine. Verified against Serper: the bare key returns 200, the same key in quotes returns 403.

  7. Report the yearly checkout gap in the health endpoint

    a297b96

    The pricing page shows a monthly/yearly toggle whether or not the yearly variant ids are configured. They are not, on production — so a customer choosing yearly reaches Lemon Squeezy and fails there. Someone trying to pay us more money, and being unable to, is the worst possible thing to discover from a support email.

  8. Limit redeemed codes to Google Maps

    bdf4fe1

    A launch code is a sample, not a free plan. Google Maps is the right thing to sample: highest hit rate, and its cost falls on our own Places quota rather than per-search credits. Every other source spends search-provider credits on every query, which is a bill a free account should not be able to run up — and with a 2,500-search monthly allowance, a handful of free accounts could empty it before a paying customer …

  9. Ask Serper for ten results a page on operator queries, not a hundred

    d658d50

    Every social source here searches with the site: operator, and Serper's free plan refuses those with any page size above ten — "Query pattern not allowed for free accounts". The code asked for a hundred every time, so on a free key not one of the nine sources could ever have returned a result, whatever key was configured.

    • Stop paging once a short page arrives; the next one costs a credit and
    • Return partial results on a mid-pagination failure instead of discarding

Thursday, 20 August 20268 changes

  1. Recover when a SerpAPI key is put in the Serper slot

    7d55f74

    Serper.dev and SerpAPI are separate companies with nearly identical names, both selling Google results, both issuing hex-string keys. Putting one in the other's environment variable is the obvious mistake, and it fails in the worst way available: every search returns zero results, the sources look broken, and the only clue is a log line saying the key was rejected — which reads as a billing problem with the key ra…

  2. Show a search happening, and make the page quotable by answer engines

    969386f

    The three steps under "How it works" describe a search; they never showed one. There is now a replay of a real search running beneath them — the query typing in, sources lighting up, the progress bar filling, rows arriving and counters climbing.

    • Add /llms.txt: a factual summary, including the limits, so a model
    • Add HowTo and WebSite structured data, generated from the same arrays the
  3. Run viral YouTube search on the official API instead of a paid scraper

    bafcd6d

    YouTube publishes an endpoint for exactly this — popular videos for a keyword, with view counts — and it is free at 10,000 quota units a day. Routing it through Apify meant paying for something Google gives away, and taking on the terms-of-service exposure of scraping to get it.

    • Accept YOUTUBE_API_KEY, falling back to the Places key, since both come from
    • Offer the YouTube platform whenever that key is set, and stop the form
    • Name the actual fix on failure: enabling the API, an API restriction that
  4. Give back credits held by searches that a restart killed

    532a566

    The job registry lives in memory, so every deploy kills whatever searches were running. Their credits had already been taken up front and nothing was left to give them back — the customer simply lost them, and would go on losing a few on every deploy until the monthly reset papered over it. Deploys are frequent and customers are about to arrive, so this had to close.

  5. Open billing in a drawer instead of leaving the dashboard

    4894d36

    Checking a credit balance mid-search meant navigating away and losing the search being configured. Checkout itself already opens as an overlay, so the trip to a separate page bought nothing — it just cost the customer their work.

  6. Make AI expansion answer in the language it was asked in

    7dc4f3e

    Expanding "matbaa" produced "matbaa services", "matbaa company", "matbaa agency" — English grammar bolted onto a Turkish noun, matching nothing a Turkish printer would ever call itself. Two faults behind one symptom.

    • Strip the city and "near me" phrasing the model adds anyway, by plain text
    • Split the Turkish categories into themed groups, so the fallback answers
  7. Stop the keyword field from looking like it rejects other languages

    1768372

    Typing a Turkish keyword against an English suggestion list produced no matches, and the menu was gated on there being matches — so the "Use …" option, the one way to enter anything not on the list, disappeared at exactly the moment it was needed. Enter still worked, but nothing on screen said so, and the honest reading was that the field refuses your language. It now appears whenever there is text in the box, wit…

    • Add Turkish business categories and the rest of the major Turkish cities,
    • Drop a duplicated .token-free rule
  8. Close a credit overspend race, and stop the search log blaming the wrong key

    f7ebe9e

    Credits were checked when a search was requested but only charged when it finished, which left the two ends of the transaction wide apart. Firing N searches at once meant all N read the same balance, all N passed the paywall, and the account spent N times its allowance — on results that cost us real money in Google Places and SerpAPI calls. A loop in the browser console was enough to do it.

    • Cap concurrent jobs at 3 per account, since each holds open paid outbound
    • Rate limit search to 6/min and AI expansion to 20/min per account

Tuesday, 18 August 20263 changes

  1. Make the search form forgiving, and give past searches a home

    71cb4c1

    Three problems, all of which read to a customer as the product being broken rather than as a mistake they made.

    • Add a combination count, since keywords multiply by cities and that is what
    • Delete HistoryList and its dead styles
  2. Publish the landing page in six languages, each on its own URL

    cdf9b5c

    A language picker that swaps text client-side would have been a fraction of this work and worth close to nothing: search engines index one page, and a visitor cannot bookmark or share the language they picked. So each locale is a real URL that renders statically, and every one declares the full set of hreflang alternates — a one-way alternate link is ignored outright.

    • Add tr, fr, it, es and de translations covering every section, including
    • Keep prices, plan structure and platform names out of the dictionaries —
    • Correct <html lang> per locale from the page, since the shared root layout
    • Add a second product view further down the page: the hero panel shows the
    • List every locale in the sitemap with its alternates
  3. Activate nine sources, and rebuild the landing page around real proof

    b2571ed

    The sources marked "soon" were not waiting on nine integrations — LinkedIn, Instagram, TikTok, Facebook, Yelp, YouTube, X, Pinterest and Shopify all discover profiles through a site: query on the search provider that is already configured and paid for. They were hidden only because listSources classified them as "direct", a label that predated the search backend. OpenStreetMap stays "soon": its public mirrors real…

    • Give the header two doors: a quiet Login and a Google button that runs
    • Fill out the footer with data sources, capabilities and a trust strip, and
    • Widen the hero panel to 1180px, add rows and an export bar so it ends on

Monday, 17 August 202628 changes

  1. Support Search Console's meta-tag verification via an env var

    4fc348d

    Google refuses to publish an OAuth consent screen whose authorized domain the project owner has not proven they control, and the DNS-record route means waiting on propagation for something that gates sign-in.

  2. Stop content showing through the sticky header, and rework /today

    8b4929f

    The header sat at 82% opacity over a backdrop blur. Where the blur does not apply — unsupported, or GPU compositing disabled — headings slid underneath it perfectly crisp and overlapped the logo row, which is what made the top of the page look broken rather than sticky.

    • Add a --nav-h token, raise the header to opaque once scrolled, and fall
    • Set scroll-padding-top on html so anchors clear the header everywhere
    • Stick the day heading below the header, and move its background onto a
    • Rework the entries as cards on a fading timeline rail, with the commit
  3. Keep the /today log intact when the build cannot see full git history

    86c1291

    The build image had no git at all: .dockerignore excluded .git, so the generator silently fell back to the committed snapshot and the page would have frozen at whatever was last pushed — the one failure mode that makes a changelog worse than useless, because it looks maintained while going stale.

    • Install git in the builder stage and drop .git from .dockerignore, so a
    • Merge by hash, git first, so a re-worded commit supersedes the stored copy
    • Drop the generated-at timestamp, which changed on every run and buried the
  4. Publish a build log at /today, generated from git history on every deploy

    0231e17

    A changelog that someone has to remember to write is abandoned within a fortnight, and a stale "what's new" page is worse than none — it tells a visitor the product is dead. So this one is derived from the commits themselves: `prebuild` runs the generator, meaning every deploy publishes exactly what shipped with it and the page cannot drift from reality.

    • Generate src/content/changelog.json from `git log`, grouped by day, using
    • Drop chore/wip/merge/typo commits, and lift the first body paragraph as
    • Commit the generated file, and keep it when git is missing from the build
    • Render /today as a sticky-dated timeline with CollectionPage JSON-LD and
    • List /today in the sitemap at daily frequency — it is the freshest page
  5. Offer only sources that have a backend, and make past searches re-runnable

    71f0f9b

    Sources: the free scrapers were selectable but not dependable. OpenStreetMap's public mirrors and the search-engine HTML endpoints answer for a few requests and then rate-limit the server, which a customer reads as a broken product rather than a missing integration. A source is now offered only when it runs on a configured provider; the rest show "soon" and light up on their own as keys are added — today that mean…

  6. Make promo codes reachable: grant access, not just credits

    e9d3b72

    A launch code was unusable by the people it targets. Redeeming lived on the dashboard, and anyone without a subscription is redirected away from it — so EARLY100 could only be entered by customers who had already paid.

    • A redeemed code now opens the dashboard for 30 days alongside its credits.
    • The redeem field appears on the billing page, where blocked visitors land,
    • Entitlement reports onPromo separately from a paid plan, so the billing page
    • Every ALTER TABLE ran before the CREATE TABLE it depended on. Existing
    • The dev file store cached the file in memory and never invalidated, serving
  7. Add promo codes, granted exactly once per account

    6d70970

    Correctness rests on the database, not on application checks. Two concurrent requests both pass an "already redeemed?" test, so the guarantee is a unique index on (promo_code_id, user_email): the losing INSERT raises 23505 and its transaction rolls back, granting nothing. A SELECT ... FOR UPDATE on the code serialises the used_count increment so max_uses cannot be overrun.

    • POST /api/promo/redeem: 404 invalid, 410 expired, 409 exhausted/already,
    • POST/GET /api/admin/promo: admin-only, non-admins get 404 rather than 403 so
    • Dashboard gains a redeem field; the meter updates from the balance the server
    • EARLY100 seeded (100 credits, 500 uses, 60 days) with ON CONFLICT DO NOTHING,
  8. Put the payment form above the fold in the checkout overlay

    ff37a93

    The product image filled the top of the overlay and pushed the card fields below the fold, so the first action a buyer had to take was scrolling. Hide the media and keep the plan description and renewal summary, which are the two things worth the vertical space at the moment of payment.

  9. Stop breaking the checkout URL signature

    e81cda4

    Clicking a plan showed LemonSqueezy's "Invalid signature" page instead of the checkout. The returned checkout URL is signed and already carries query parameters, and the overlay call appended a second "?embed=1" to it — producing "...?custom=1?embed=1" and invalidating the signature.

  10. Add a 1600x1200 product image for the billing provider

    f10de74

    LemonSqueezy shows this at checkout, in receipt emails and on social shares, so it carries the brand at the moment a customer decides to pay. Rendered from SVG at the 4:3 size the uploader recommends.

  11. New hexagon logo, downloadable search history, overlay checkout and a comparison section

    b903ad0

    Logo: hexagonal badge with a cut-out G, and the wordmark set as "GoLeadX" in mixed case everywhere. That casing also matches the OAuth consent-screen name, which the brand review flagged as a mismatch. PNGs regenerated from source.

  12. Add Excel and JSON exports, analytics, and a payment thank-you email

    8614e7b

    Export: a comma-separated file opens as one unreadable column in any locale whose list separator is a semicolon, which is most of Europe including Turkish. The export button becomes a picker: - Excel — semicolon-delimited with a sep= hint, so columns land correctly - CSV — comma-separated, for Instantly/Smartlead/Apollo - JSON — full records, including the nested socials the flat formats flatten Field quoting is n…

    • Excel — semicolon-delimited with a sep= hint, so columns land correctly
    • CSV — comma-separated, for Instantly/Smartlead/Apollo
    • JSON — full records, including the nested socials the flat formats flatten
  13. Run discovery concurrently and show rows as they arrive

    b7562cb

    A search took minutes because every source, keyword and city ran in sequence: one slow provider held up every other source behind it, and the table stayed empty until the very last one finished.

    • Build the source x keyword x location work list up front and run it through a
    • Publish de-duplicated leads after each unit completes, so rows appear while
    • Race the Overpass mirrors with Promise.any at 25s instead of walking them in
  14. Report the real search-provider error instead of blaming a missing key

    4e50eac

    The job log said "Add SERPER_API_KEY" while the key was configured and being rejected with 403, which sent the operator looking in the wrong place. The provider's actual failure now reaches the job log: a rejected key, an exhausted quota and a network error each read differently.

  15. Return businesses, not streets, and find missing websites

    e894731

    A search for "matbaa" in Ankara returned seven rows with no email, phone or website. Six of them were not businesses: the OSM name filter matched streets ("Matbaacılar Sokak"), a district and an industrial estate, none of which have contact details to extract.

    • Drop map entries tagged as highway/place/landuse and friends, and require
    • Keep the Overpass query to a single name match: combining the regex with each
    • Add print, signage, tailor, architect and other missing category tags.
    • Look up the website of leads that have none, when a paid search backend is
    • Backends now derive their own page count from the number of results actually
  16. Add email + password sign-up and a welcome email

    fbb2b4c

    Google was the only way in, which turns away anyone who does not have or does not want to use a Google account.

    • Credentials provider alongside Google; both resolve to one account keyed by
    • Passwords hashed with Node's built-in scrypt. No native dependency, so the
    • Sign-in page becomes a two-tab form. Google appears only when configured, so
    • Welcome email through Resend on first sign-in, explaining the three modes,
  17. Add an SEO blog and a low-cost search backend

    b198d36

    Blog: - File-backed posts: drop a Markdown file in src/content/blog and it appears in the index and the sitemap, with no list to keep in sync - Per-post canonical, OpenGraph and BlogPosting + BreadcrumbList JSON-LD - Three launch posts written against real search intent rather than keyword padding: Google Maps email extraction, cold-email deliverability, and building an agency pipeline - Footer extracted to a shar…

    • File-backed posts: drop a Markdown file in src/content/blog and it appears in
    • Per-post canonical, OpenGraph and BlogPosting + BreadcrumbList JSON-LD
    • Three launch posts written against real search intent rather than keyword
    • Footer extracted to a shared component so every page carries the same nav;
    • Serper.dev backend, tried before SerpAPI and the free scraper. It bills per
    • /api/health now reports which search backend is actually live
  18. Add sitemap.xml and robots.txt

    7e1c3c0

    Search Console had nothing to submit. Lists only the public pages — the dashboard, billing and sign-in routes redirect signed-out visitors, so indexing them would put login redirects in search results.

  19. Add an account menu with sign-out to the dashboard

    c9e3e63

    The dashboard had no way out — sign-out existed only on the billing page, so the only route back to a signed-out state was editing the URL. The account card now carries a dropdown with billing, back-to-site and sign-out; it closes on outside click and Escape.

  20. Stop payment events from wiping an active subscription

    5bcd764

    subscription_payment_success carries a subscription *invoice*, not a subscription: its status is "paid". The handler read that as a subscription status, fell through to "none", and revoked access seconds after a successful checkout — the customer got in once, then was bounced back to /billing.

    • Split lifecycle events (which carry a subscription) from invoice events
    • An unrecognised status now keeps the stored one instead of downgrading, so no
    • Preserve renews_at/ends_at when an event does not carry them.
    • Add syncSubscriptionFromProvider(): when local state says inactive, re-read
  21. State the app name and purpose on the homepage for OAuth brand verification

    3274006

    Google's brand review rejected the app twice: the homepage did not explain what the product does, and the stylised all-caps wordmark did not read as a match for the "GoLeadX" name on the consent screen.

    • Add an "What GoLeadX does" section in plain language, including what Google
    • Spell the product "GoLeadX" in the title, metadata and hero copy so the name
    • Add applicationName/openGraph siteName for machine-readable identity
  22. Add follower scraping, viral content mining, AI expansion and bulk search

    08e8f64

    Actor ids are env-overridable — the Apify store renames actors, and a rename should be a config change rather than a redeploy.

    • Search modes: leads (existing), followers, and viral short-form content.
    • AI query expansion via NVIDIA's OpenAI-compatible endpoint, with a curated
    • Bulk search: several locations per run, each keyword crossed with each city.
    • Viral results export as their own CSV shape (videos, not contacts).
    • Dashboard reorganised: mode tabs, per-mode inputs and stats, video table.
  23. Add owner bypass, yearly billing cycle and customer-facing plan features

    ee27273
    • ADMIN_EMAILS grants listed accounts dashboard access with unlimited credits,
    • Plans now carry monthly and yearly variants; the billing page gets the same
    • Feature lists renamed to what customers buy rather than how it is built —
    • /api/health reports the admin account count (never the addresses)
  24. Use real brand marks in the source marquee, add a deployment health check

    5c22667
    • Replace the initials-in-a-circle placeholders with simple-icons brand paths
    • Add GET /api/health reporting which settings are present, whether the
  25. Add privacy, terms and refund pages

    ed19800

    Google's OAuth branding verification and LemonSqueezy onboarding both require reachable policy pages; the footer previously linked to "#". Content reflects how the service actually works: Google-only auth, LemonSqueezy as merchant of record, public-source collection, and credit-based plans.

  26. Name the missing env var on the sign-in page

    687e65b

    Auth.js collapses every setup problem into a generic "Configuration" error, which gives no clue which variable is absent. Check AUTH_SECRET alongside the Google pair and list exactly what the server is missing.

  27. Add PNG brand assets for OAuth and billing provider uploads

    5b60599

    Google's consent screen and LemonSqueezy both reject SVG, so render the square mark (512/120) and the horizontal lockup from source SVGs.

  28. GoLeadX: lead-gen SaaS with Google auth, LemonSqueezy billing and Apify scraping

    26c4b81
    • Next.js 15 App Router app: marketing site, sign-in, billing and dashboard
    • Scraping engine: pluggable source adapters, site crawler, email extraction
    • Sources resolve to Apify actors when APIFY_TOKEN is set, falling back to
    • Google sign-in via Auth.js with JWT sessions
    • LemonSqueezy checkout plus signature-verified webhooks driving entitlement
    • Paywall enforced in middleware, page and every API route; jobs are scoped