Back to all projects

Dec 2022 – Feb 2023

SWE

Last edited

End-to-End Client Acquisition System

This project engineered the core operations infrastructure for BlinkWeb, a CRM and client-acquisition engine that integrated the Norwegian Brønnøysund Register and three phone registries (1881, 180, Gule Sider) via REST APIs to automate data enrichment and contact verification.

Geospatial lead-matching algorithms reduced false positives in candidate identification, and automated outreach plus real-time contract generation eliminated manual handoffs end-to-end.

The system saved approximately 450 hours per year and reduced third-party API costs by roughly $10,000, serving as core infrastructure behind BlinkWeb's operations in 2024.

Affiliation

BlinkWeb

Keywords

  • CRM
  • Data Enrichment
  • Data Validation
  • Python
  • REST APIs
  • MySQL
  • Web Scraping

Deepdive

Introduction

I started my company right after high school in June 2022. The hard part was never building the product, it was finding clients to build for. The daily routine was to manually pull newly registered companies from Brønnøysundsregisteret, cross-reference each one against 1881, Gule Sider, and 180 to recover a phone number, strip out anyone on the do-not-call registry, and only then assemble a call list. Repeated every morning, the workflow consumed several hours before the first dial. This project automates that pipeline end-to-end.

Problem Definition

The acquisition funnel for a B2B service business in Norway starts with a single question: which companies were registered yesterday, and how do I reach them legally today? Each candidate company cc is characterised by a tuple (orgnrc,namec,naicsc,ϕc,dncc)(\mathrm{orgnr}_c,\, \mathrm{name}_c,\, \mathrm{naics}_c,\, \phi_c,\, \mathrm{dnc}_c), organisation number, legal name, industry code, phone number, and do-not-call status. Brønnøysundsregisteret exposes the first four fields (with ϕc\phi_c frequently missing), while the public directories 1881, Gule Sider, and 180 expose ϕc\phi_c but not the registry metadata. The do-not-call flag dncc\mathrm{dnc}_c is published separately by Brønnøysundsregisteret as the Reservasjonsregisteret. A usable lead is any cc with all five fields resolved and dncc=0\mathrm{dnc}_c = 0. The system must produce, every day and without human intervention, the set

L  =  {c  :  ϕc    dncc=0    cCnew},L \;=\; \bigl\{\, c \;:\; \phi_c \neq \emptyset \;\wedge\; \mathrm{dnc}_c = 0 \;\wedge\; c \in C_{\text{new}} \,\bigr\},

where CnewC_{\text{new}} is the set of companies registered in the previous 24 hours.

Approach

Architecture diagram of the client acquisition pipeline: Brønnøysundsregisteret REST APIs feed an enrichment stage that web-scrapes 1881, Gule Sider, and 180 for phone numbers, then filters against the do-not-call registry and writes the result to MySQL.
End-to-end architecture. Newly registered companies flow from Brønnøysundsregisteret through phone-number enrichment via Selenium-driven scrapes of the public directories, are filtered against the Reservasjonsregisteret, and land in MySQL as a ready-to-call lead list.

The pipeline runs as a daily scheduled job and decomposes into four stages: registry ingestion, phone-number enrichment, do-not-call filtering, and persistence.

Registry Ingestion

The first stage queries the open Brønnøysundsregisteret REST API for all units registered in the last 24 hours, filtered by the industry codes relevant to the service offering. Each response is paginated JSON, and the relevant fields (orgnrc,namec,naicsc,ϕcreg)(\mathrm{orgnr}_c,\, \mathrm{name}_c,\, \mathrm{naics}_c,\, \phi_c^{\text{reg}}) are unpacked into an in-memory candidate set CnewC_{\text{new}}. The registry phone number ϕcreg\phi_c^{\text{reg}} is present for roughly a third of new companies; the remaining two thirds carry ϕcreg=\phi_c^{\text{reg}} = \emptyset and have to be enriched downstream.

Phone-Number Enrichment

For every cc with ϕcreg=\phi_c^{\text{reg}} = \emptyset, a Selenium-driven Google Chrome session searches 1881, Gule Sider, and 180 in turn using namec\mathrm{name}_c and orgnrc\mathrm{orgnr}_c as the query keys. Each directory returns at most one candidate number per source, giving a per-company vector ϕc=(ϕc1881,ϕcGS,ϕc180,ϕcreg)\vec{\phi}_c = (\phi_c^{1881},\, \phi_c^{\text{GS}},\, \phi_c^{180},\, \phi_c^{\text{reg}}). The enriched number is resolved by majority vote with the registry value as a tiebreaker:

ϕc  =  argmaxϕϕc  # ⁣{s:ϕcs=ϕ},s{1881,GS,180,reg}.\phi_c \;=\; \arg\max_{\phi \in \vec{\phi}_c} \; \#\!\left\{ s \,:\, \phi_c^{s} = \phi \right\}, \qquad s \in \{1881,\, \text{GS},\, 180,\, \text{reg}\}.

When all four sources disagree, the registry value wins if present; otherwise the highest-confidence directory hit is taken. Scrapes run with randomised user agents and request spacing to stay within each directory’s rate limits.

Do-Not-Call Filtering

The Reservasjonsregisteret is queried in batch for every ϕc\phi_c in the enriched set, returning the binary flag dncc{0,1}\mathrm{dnc}_c \in \{0, 1\}. Companies with dncc=1\mathrm{dnc}_c = 1 are dropped before persistence; calling them is illegal under Norwegian marketing law (markedsføringsloven § 12), so the filter is enforced at the pipeline boundary rather than at the call-centre boundary, eliminating any chance of a reserved number leaking into a call list.

Persistence

The surviving leads LL are written to a MySQL database with a uniqueness constraint on orgnrc\mathrm{orgnr}_c, so reruns are idempotent and the table doubles as a deduplication layer across days. Each row carries a timestamp, the source vector ϕc\vec{\phi}_c, and the resolved ϕc\phi_c, which lets the CRM front-end surface the call list and lets the enrichment stage be audited after the fact when a number turns out to be wrong.

Results

The pipeline replaced a multi-hour daily manual workflow with a scheduled job that runs unattended and produces a deduplicated, legally compliant call list every morning.

  • Roughly 450 hours per year reclaimed from manual list-building, redirected into actual client conversations and product work.
  • Approximately 10,00010{,}000 per year in avoided costs, measured against the two realistic alternatives: hiring a part-time researcher to do the same work by hand, or paying for the commercial 1881 REST API at the volume the business required.

Future Work

The current enrichment stage is brittle in the way every Selenium-based scrape is brittle: the moment 1881, Gule Sider, or 180 reshuffles their DOM, the corresponding extractor breaks silently and ϕc\vec{\phi}_c loses a coordinate. A natural next step is to wrap each directory extractor in a schema check that compares the parsed response against a stored fingerprint and raises before a malformed row reaches the voting stage, so a layout change degrades the pipeline gracefully instead of poisoning the lead list.

A more fundamental redesign is to replace the majority-vote resolver with a learned model over ϕc\vec{\phi}_c that predicts P(ϕϕc,naicsc,namec)P(\phi \mid \vec{\phi}_c,\, \mathrm{naics}_c,\, \mathrm{name}_c) and outputs both a resolved number and a calibrated confidence. The current voter treats every source as equally reliable, but in practice 1881 dominates on established businesses while the registry value dominates on companies less than a week old, and a model conditioned on company age and industry would capture that structure directly. Confidence scores would also let the call list be sorted by reachability rather than by registration date, which is the single largest remaining lever on conversion rate from the top of the funnel.