FreeDon't want to build it yourself? We'll build your pre-landers.Have us build it for you →
Shared guide ยท for your developer

The back-end blueprint (the formula)

Your pages qualify the visitor and confirm the property address. Your server keeps the keys, logs the clicks and filters the bots. Our page collects the contact details and does everything that touches consent. This page shows who does what, the full data flow, and seven small parts your server needs.

The split of work

Three places, three jobs. The line that matters most: your side never asks for a name, phone number or email address.

Your pages

Qualify and confirm the address

Static HTML, CSS and JavaScript, for example from our kit, on your own domain.

  • Two or three quick qualifying questions
  • The property address, with autocomplete
  • A picture of the house with one confirm button
  • The hand-off link to our page
More
  • A kind exit page for visitors who do not fit your questions
  • Your own Terms, Privacy and disclaimer
Your server

Keep keys, logs and bots in check

A small back end on any stack. Nothing here is visible to the visitor.

  • Logs every click with your click ID
  • Calls the address and house-picture services, so the keys stay private
  • Logs each step and filters bots
  • Receives the sold-lead postback and tells Meta
More
  • Notes whether each ZIP is on the coverage list, refreshed daily
Our page

Contact details, consent, verification

SellHouseFast runs this. You do not build or host any of it.

  • Shows the house and asks the owner and timeline questions
  • Collects name, phone and email under our consent text
  • Captures the TrustedForm certificate and runs a bot check
  • Verifies the phone with a text code
More
  • Verifies the lead, delivers it to buyers, and reports a sale back through your tracking link

The data flow

One visitor, from the click to the sold-lead signal that lands back in your Meta account.

  1. The clickYour pages

    An ad, a text, an email or a path question. The visitor taps.

  2. Your tracking linkYour server

    Carries sub1 the source, sub2 your click ID, sub3 the creative. Your server logs the click.

  3. Qualifying questionsYour pages

    Owner? Listed with an agent? How soon? Visitors who do not fit go to your exit page.

  4. The address, with autocompleteYour server

    Your server asks the address service. The key never reaches the browser.

  5. Coverage noteYour server

    The ZIP is checked against the public coverage list and recorded as covered or not. Nothing is blocked; coverage tells you where leads sell best.

  6. The house pictureYour server

    Your server checks an image exists, fetches it, caches it, and falls back to a plain picture.

  7. The confirm tapYour pages

    "Yes, this is my home." The bot filter runs here, and this is the first moment you count the visitor.

  8. Hand-offYour pages

    Your tracking link plus street, city, state, zip, and optionally lat, lng.

  9. Hand-off linkOur pageSellHouseFast

    House picture, owner and timeline questions, contact details, our consent text, TrustedForm certificate, phone code, bot check.

  10. Lead verified, then sold or unsoldSellHouseFast

    We verify the lead and offer it to buyers who cover the area. Not every lead sells.

  11. Conversion on a saleSellHouseFast

    When a lead sells, a conversion is recorded against your click, server to server.

  12. PostbackYour postback: sub2 and the payoutYour server

    Your tracking account calls the postback URL you set, with your click ID and the payout.

  13. Your server tells MetaYour server

    It sends a sold-lead event to the Meta Conversions API, matched to the click you logged.

The seven parts your server needs

The minimum back end. Each part is small. None of them is in our own code for you to copy, so treat these as our recommendations and test them. Every one carries a label so you know how sure we are.

  1. Click logger From our experience

    What it does
    Stores one record per click: your click ID, the time, the source and ad, and Meta's click data.
    Build it
    Why it exists
    When a sale comes back after the visit, this record is how you find the click and send Meta a matched event. Without it the sale is a number with nowhere to go.
    How to build it
    On the first request, make a short random click ID, read fbclid from the URL and the _fbc and _fbp cookies, and write one row. Put the click ID into sub2 of your tracking link.
    {
      "click_id": "c_8f2k1x",
      "time": "2026-10-22T15:04:05Z",
      "source": "meta",
      "ad_id": "AD_ID",
      "fbclid": "FBCLID_FROM_URL",
      "fbc": "VALUE_OF__fbc_COOKIE",
      "fbp": "VALUE_OF__fbp_COOKIE",
      "ip": "203.0.113.7",
      "user_agent": "Mozilla/5.0 (iPhone; ...)",
      "counted": false
    }
  2. Address lookup proxy From our experience

    What it does
    Turns what the visitor types into a list of real addresses, through any address provider you choose.
    Build it
    Why it exists
    A key in the browser can be copied and run up on your bill. Our own page keeps its keys on the server for the same reason.
    How to build it
    One small route on your server that adds the key, limits US results, caches common queries and returns only the fields you need.

    JavaScript (Node)

    // Your server. The browser calls /api/address?q=123+Main
    // Node 18 or newer: built-in http and fetch, nothing to install.
    var http = require('http');
    
    var ADDRESS_URL = process.env.ADDRESS_URL;   // your provider's autocomplete address
    var ADDRESS_KEY = process.env.ADDRESS_KEY;   // stays on the server, never in the page
    var MAX_PER_MINUTE = Number(process.env.MAX_PER_MINUTE || 30);
    var DAY = 24 * 60 * 60 * 1000;
    var cache = new Map();   // query: { results, until }
    var hits = new Map();    // client IP: { count, until }
    
    function rateLimited(ip) {
      var now = Date.now();
      var h = hits.get(ip);
      if (!h || h.until < now) {
        h = { count: 0, until: now + 60 * 1000 };
        hits.set(ip, h);
      }
      h.count += 1;
      return MAX_PER_MINUTE < h.count;
    }
    
    // Rename your provider's fields here. Return only what the page needs.
    function toAddress(item) {
      return { street: item.street, city: item.city, state: item.state, zip: item.zip, lat: item.lat, lng: item.lng };
    }
    
    function lookup(q) {
      var url = new URL(ADDRESS_URL);
      url.searchParams.set('q', q);
      url.searchParams.set('country', 'US');
      url.searchParams.set('key', ADDRESS_KEY);   // or a header, as your provider asks
      return fetch(url).then(function (res) {
        if (!res.ok) throw new Error('provider answered ' + res.status);
        return res.json();
      }).then(function (body) {
        return (body.results || []).map(toAddress);
      });
    }
    
    function reply(res, status, body) {
      res.writeHead(status, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(body));
    }
    
    http.createServer(function (req, res) {
      var url = new URL(req.url, 'http://localhost');
      if (url.pathname !== '/api/address') return reply(res, 404, []);
      var q = (url.searchParams.get('q') || '').trim();
      if (q.length < 4) return reply(res, 200, []);   // too short, do not call the provider
      if (rateLimited(req.socket.remoteAddress)) return reply(res, 429, []);
      var hit = cache.get(q);
      if (hit && Date.now() < hit.until) return reply(res, 200, hit.results);
      lookup(q).then(function (results) {
        cache.set(q, { results: results, until: Date.now() + DAY });
        reply(res, 200, results);
      }).catch(function () {
        reply(res, 502, []);
      });
    }).listen(Number(process.env.PORT || 3000));

    Python

    # Your server. The browser calls /api/address?q=123+Main
    # Python 3 standard library only.
    import json, os, time
    from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
    from urllib.parse import urlparse, parse_qs, urlencode
    from urllib.request import urlopen
    
    ADDRESS_URL = os.environ["ADDRESS_URL"]   # your provider's autocomplete address
    ADDRESS_KEY = os.environ["ADDRESS_KEY"]   # stays on the server, never in the page
    MAX_PER_MINUTE = int(os.environ.get("MAX_PER_MINUTE", "30"))
    DAY = 24 * 60 * 60
    cache = {}   # query: (results, until)
    hits = {}    # client IP: (count, until)
    
    def rate_limited(ip):
        now = time.time()
        count, until = hits.get(ip, (0, 0))
        if until < now:
            count, until = 0, now + 60
        hits[ip] = (count + 1, until)
        return MAX_PER_MINUTE < count + 1
    
    # Rename your provider's fields here. Return only what the page needs.
    def to_address(item):
        return {k: item.get(k) for k in ("street", "city", "state", "zip", "lat", "lng")}
    
    def lookup(q):
        query = urlencode({"q": q, "country": "US", "key": ADDRESS_KEY})   # or a header, as your provider asks
        with urlopen(ADDRESS_URL + "?" + query, timeout=5) as r:
            body = json.load(r)
        return [to_address(item) for item in body.get("results", [])]
    
    class Handler(BaseHTTPRequestHandler):
        def do_GET(self):
            url = urlparse(self.path)
            if url.path != "/api/address":
                return self.reply(404, [])
            q = parse_qs(url.query).get("q", [""])[0].strip()
            if len(q) < 4:
                return self.reply(200, [])   # too short, do not call the provider
            if rate_limited(self.client_address[0]):
                return self.reply(429, [])
            hit = cache.get(q)
            if hit and time.time() < hit[1]:
                return self.reply(200, hit[0])
            try:
                results = lookup(q)
            except Exception:
                return self.reply(502, [])
            cache[q] = (results, time.time() + DAY)
            self.reply(200, results)
    
        def reply(self, status, body):
            data = json.dumps(body).encode()
            self.send_response(status)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(data)))
            self.end_headers()
            self.wfile.write(data)
    
    ThreadingHTTPServer(("", int(os.environ.get("PORT", "3000"))), Handler).serve_forever()

    Plain steps

    // Your server. The browser calls /api/address?q=123+Main
    on GET /api/address:
      q = trim(query.q)
      if length(q) < 4: return []            // too short, do not call the provider
      if rate_limited(client_ip): return 429
      results = cache.get(q)
      if not results:
        results = provider.autocomplete(q, key = ENV.ADDRESS_KEY, country = "US")
        cache.set(q, results, 1 day)
      return each result as: street, city, state, zip, lat, lng
  3. House picture proxy From our experience

    What it does
    Fetches a street-level picture of the confirmed address, or a plain fallback picture if none exists.
    Build it
    Why it exists
    Seeing their own home builds trust. Street imagery costs climb fast at scale, and a blank grey box looks broken. Our own page checks first and falls back the same way.
    How to build it
    Ask the provider whether an image exists before you fetch one, cache by rounded coordinates for as long as the provider's terms allow, and check the current price of both calls before you launch.

    JavaScript (Node)

    // Your server. The page loads /api/house-image?lat=39.7525&lng=-104.9995 as the picture.
    // Node 18 or newer: built-in http and fetch, nothing to install.
    var http = require('http');
    var fs = require('fs');
    
    // Any street imagery provider. Ask it whether a picture exists before you fetch one,
    // and check the current price of both calls before you launch.
    var CHECK_URL = process.env.IMAGE_CHECK_URL;   // the call that says whether a picture exists
    var IMAGE_URL = process.env.IMAGE_URL;         // the call that returns the picture
    var IMAGE_KEY = process.env.IMAGE_KEY;         // stays on the server, never in the page
    var FALLBACK = fs.readFileSync('fallback-house.jpg');   // a plain picture of a house
    var cache = new Map();   // rounded spot: { type, bytes }
    
    function providerUrl(base, lat, lng) {
      var url = new URL(base);
      url.searchParams.set('location', lat + ',' + lng);
      url.searchParams.set('key', IMAGE_KEY);
      return url;
    }
    
    function fetchPicture(lat, lng) {
      return fetch(providerUrl(CHECK_URL, lat, lng)).then(function (res) {
        return res.json();
      }).then(function (check) {
        if (!check.found) return null;   // map your provider's answer here
        return fetch(providerUrl(IMAGE_URL, lat, lng)).then(function (res) {
          if (!res.ok) return null;
          return res.arrayBuffer().then(function (buf) {
            return { type: res.headers.get('content-type') || 'image/jpeg', bytes: Buffer.from(buf) };
          });
        });
      });
    }
    
    function send(res, picture) {
      res.writeHead(200, { 'Content-Type': picture ? picture.type : 'image/jpeg' });
      res.end(picture ? picture.bytes : FALLBACK);
    }
    
    http.createServer(function (req, res) {
      var url = new URL(req.url, 'http://localhost');
      if (url.pathname !== '/api/house-image') { res.writeHead(404); return res.end(); }
      var lat = parseFloat(url.searchParams.get('lat'));
      var lng = parseFloat(url.searchParams.get('lng'));
      if (isNaN(lat) || isNaN(lng)) return send(res, null);
      var spot = lat.toFixed(5) + ',' + lng.toFixed(5);
      if (cache.has(spot)) return send(res, cache.get(spot));
      fetchPicture(lat, lng).then(function (picture) {
        if (picture) cache.set(spot, picture);   // as long as the provider's terms allow
        send(res, picture);
      }).catch(function () {
        send(res, null);
      });
    }).listen(Number(process.env.PORT || 3000));

    Python

    # Your server. The page loads /api/house-image?lat=39.7525&lng=-104.9995 as the picture.
    # Python 3 standard library only.
    import json, os
    from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
    from urllib.parse import urlparse, parse_qs, urlencode
    from urllib.request import urlopen
    
    # Any street imagery provider. Ask it whether a picture exists before you fetch one,
    # and check the current price of both calls before you launch.
    CHECK_URL = os.environ["IMAGE_CHECK_URL"]   # the call that says whether a picture exists
    IMAGE_URL = os.environ["IMAGE_URL"]         # the call that returns the picture
    IMAGE_KEY = os.environ["IMAGE_KEY"]         # stays on the server, never in the page
    with open("fallback-house.jpg", "rb") as f:
        FALLBACK = ("image/jpeg", f.read())     # a plain picture of a house
    cache = {}   # rounded spot: (type, bytes)
    
    def provider_url(base, lat, lng):
        return base + "?" + urlencode({"location": "%s,%s" % (lat, lng), "key": IMAGE_KEY})
    
    def fetch_picture(lat, lng):
        with urlopen(provider_url(CHECK_URL, lat, lng), timeout=5) as r:
            check = json.load(r)
        if not check.get("found"):   # map your provider's answer here
            return None
        with urlopen(provider_url(IMAGE_URL, lat, lng), timeout=10) as r:
            return (r.headers.get("Content-Type", "image/jpeg"), r.read())
    
    class Handler(BaseHTTPRequestHandler):
        def do_GET(self):
            url = urlparse(self.path)
            if url.path != "/api/house-image":
                self.send_error(404)
                return
            query = parse_qs(url.query)
            try:
                lat = float(query["lat"][0])
                lng = float(query["lng"][0])
            except (KeyError, ValueError):
                return self.picture(FALLBACK)
            spot = "%.5f,%.5f" % (lat, lng)
            if spot in cache:
                return self.picture(cache[spot])
            try:
                found = fetch_picture(lat, lng)
            except Exception:
                found = None
            if found:
                cache[spot] = found   # as long as the provider's terms allow
            self.picture(found or FALLBACK)
    
        def picture(self, found):
            kind, data = found
            self.send_response(200)
            self.send_header("Content-Type", kind)
            self.send_header("Content-Length", str(len(data)))
            self.end_headers()
            self.wfile.write(data)
    
    ThreadingHTTPServer(("", int(os.environ.get("PORT", "3000"))), Handler).serve_forever()

    Plain steps

    on GET /api/house-image?lat=..&lng=..:
      spot = round(lat, 5) + "," + round(lng, 5)
      if cache.has(spot): return cache.get(spot)
      check = provider.image_exists(lat, lng, key = ENV.IMAGE_KEY)
      if not check.found: return FALLBACK_PICTURE
      picture = provider.fetch_image(lat, lng, key = ENV.IMAGE_KEY)
      cache.set(spot, picture)                // as long as the provider's terms allow
      return picture
  4. Coverage note From our experience

    What it does
    Records whether the confirmed ZIP is on our coverage list, as covered true or false in your event data. It never turns a visitor away or sends anyone to an exit page.
    Build it
    Why it exists
    Our coverage page lists the counties, and their ZIPs, where we sell most reliably today. We take traffic from all fifty states, so nothing is blocked; the note shows you where your leads sell best, so you can aim at the more densely covered areas.
    How to build it
    Download the ZIP list from the coverage site once a day and keep it in memory as a set. If a download fails, keep yesterday's list. Add covered to the address_confirmed line of part 5.
    once a day:
      rows = download(COVERAGE_ZIP_LIST)
      if rows is not empty: covered_zips = set of 5-digit ZIPs in rows
    
    on address confirmed:
      covered = zip in covered_zips          // true or false, nobody is turned away
      log("address_confirmed", click_id, zip, covered)
      continue to the house picture
  5. Step and event logger Untested

    What it does
    Writes one line per step per visitor, with the click ID, and fires the same events to your pixel.
    Build it
    Why it exists
    It shows where visitors drop out of your page, so you can fix the right step. It also gives you your own raw-lead count to compare with sales.
    How to build it
    Use the event names below. Log on the server, not only in the browser, because ad blockers drop browser events.
    {"click_id": "c_8f2k1x", "event": "address_confirmed", "time": "2026-10-22T15:05:12Z", "zip": "80202"}
  6. Bot filter From our experience

    What it does
    Stops automated clicks from counting as visitors or reaching our page.
    Build it
    Why it exists
    On text and email especially, many clicks are not people: link scanners and preview bots open links on their own. A real tap is the first honest signal.
    How to build it
    Five rules, all on the confirm tap:
    • An invisible human-check challenge on the confirm tap (any provider).
    • Block data-center and hosting networks.
    • A per-IP rate limit.
    • A minimum time on page before the tap is accepted.
    • Count nothing on a raw click. A visitor counts only after a tap that passes the rules above.

    JavaScript (Node)

    // Your server. The page posts the confirm tap to /api/confirm as JSON:
    //   { "click_id": "c_8f2k1x", "challenge_token": "...", "address": { "street": "123 Main St", ... } }
    // and then goes wherever the answer's "go" field points.
    // Node 18 or newer: built-in http and fetch, nothing to install.
    var http = require('http');
    var fs = require('fs');
    
    var EXIT_URL = process.env.EXIT_URL;                  // your kind exit page
    var TRACKING_LINK = process.env.TRACKING_LINK;        // your tracking link, as we gave it to you
    var MAX_PER_HOUR = Number(process.env.MAX_PER_HOUR);  // set both yourself,
    var MIN_SECONDS = Number(process.env.MIN_SECONDS);    // then tune them after a week of logs
    if (!MAX_PER_HOUR || !MIN_SECONDS) throw new Error('Set MAX_PER_HOUR and MIN_SECONDS');
    
    // Data-center and hosting network ranges, from a list you trust. A prefix match keeps it simple.
    var DATACENTER_PREFIXES = (process.env.DATACENTER_PREFIXES || '').split(',').filter(Boolean);
    var clicks = new Map();   // click_id: the record your click logger wrote. Use your database.
    var perIp = new Map();    // client IP: { count, until }
    
    // Any invisible human-check provider: send its token and your secret key to its verify address.
    function challengePassed(token) {
      var form = new URLSearchParams({ secret: process.env.CHALLENGE_SECRET, response: token || '' });
      return fetch(process.env.CHALLENGE_VERIFY_URL, { method: 'POST', body: form })
        .then(function (res) { return res.json(); })
        .then(function (answer) { return answer.success === true; })
        .catch(function () { return false; });
    }
    
    function isDatacenterNetwork(ip) {
      return DATACENTER_PREFIXES.some(function (prefix) { return ip.indexOf(prefix) === 0; });
    }
    
    function overRateLimit(ip) {
      var now = Date.now();
      var h = perIp.get(ip);
      if (!h || h.until < now) {
        h = { count: 0, until: now + 60 * 60 * 1000 };
        perIp.set(ip, h);
      }
      h.count += 1;
      return MAX_PER_HOUR < h.count;
    }
    
    // One line per step per visitor, the same names you fire to your pixel.
    function log(event, click, zip) {
      var line = { click_id: click.click_id, event: event, time: new Date().toISOString(), zip: zip };
      fs.appendFile('events.log', JSON.stringify(line) + '\n', function () {});
    }
    
    function buildHandoffLink(click, address) {
      var url = new URL(TRACKING_LINK);
      url.searchParams.set('sub1', click.source);
      url.searchParams.set('sub2', click.click_id);
      url.searchParams.set('sub3', click.ad_id);
      url.searchParams.set('street', address.street || '');
      url.searchParams.set('city', address.city || '');
      url.searchParams.set('state', String(address.state || '').toUpperCase().slice(0, 2));
      url.searchParams.set('zip', String(address.zip || '').slice(0, 5));
      return url.toString();
    }
    
    function reply(res, go) {
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ go: go }));
    }
    
    function readJson(req) {
      return new Promise(function (resolve) {
        var body = '';
        req.on('data', function (chunk) { body += chunk; });
        req.on('end', function () {
          try { resolve(JSON.parse(body)); } catch (e) { resolve({}); }
        });
      });
    }
    
    http.createServer(function (req, res) {
      if (req.method !== 'POST' || req.url !== '/api/confirm') { res.writeHead(404); return res.end(); }
      var ip = req.socket.remoteAddress;   // behind a proxy, read the address your proxy forwards
      readJson(req).then(function (body) {
        var click = clicks.get(body.click_id);
        var address = body.address || {};
        if (!click) return reply(res, EXIT_URL);
        return challengePassed(body.challenge_token).then(function (passed) {
          if (!passed) return reply(res, EXIT_URL);
          if (isDatacenterNetwork(ip)) return reply(res, EXIT_URL);
          if (overRateLimit(ip)) return reply(res, EXIT_URL);
          if ((Date.now() - Date.parse(click.time)) / 1000 < MIN_SECONDS) return reply(res, EXIT_URL);
          click.counted = true;
          log('address_confirmed', click, String(address.zip || '').slice(0, 5));
          reply(res, buildHandoffLink(click, address));
        });
      });
    }).listen(Number(process.env.PORT || 3000));

    Python

    # Your server. The page posts the confirm tap to /api/confirm as JSON:
    #   {"click_id": "c_8f2k1x", "challenge_token": "...", "address": {"street": "123 Main St", ...}}
    # and then goes wherever the answer's "go" field points.
    # Python 3 standard library only.
    import json, os, time
    from datetime import datetime, timezone
    from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
    from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode
    from urllib.request import urlopen
    
    EXIT_URL = os.environ["EXIT_URL"]                 # your kind exit page
    TRACKING_LINK = os.environ["TRACKING_LINK"]       # your tracking link, as we gave it to you
    MAX_PER_HOUR = int(os.environ["MAX_PER_HOUR"])    # set both yourself,
    MIN_SECONDS = float(os.environ["MIN_SECONDS"])    # then tune them after a week of logs
    
    # Data-center and hosting network ranges, from a list you trust. A prefix match keeps it simple.
    DATACENTER_PREFIXES = [p for p in os.environ.get("DATACENTER_PREFIXES", "").split(",") if p]
    clicks = {}   # click_id: the record your click logger wrote. Use your database.
    per_ip = {}   # client IP: (count, until)
    
    # Any invisible human-check provider: send its token and your secret key to its verify address.
    def challenge_passed(token):
        form = urlencode({"secret": os.environ["CHALLENGE_SECRET"], "response": token or ""}).encode()
        try:
            with urlopen(os.environ["CHALLENGE_VERIFY_URL"], data=form, timeout=5) as r:
                return json.load(r).get("success") is True
        except Exception:
            return False
    
    def is_datacenter_network(ip):
        return any(ip.startswith(prefix) for prefix in DATACENTER_PREFIXES)
    
    def over_rate_limit(ip):
        now = time.time()
        count, until = per_ip.get(ip, (0, 0))
        if until < now:
            count, until = 0, now + 60 * 60
        per_ip[ip] = (count + 1, until)
        return MAX_PER_HOUR < count + 1
    
    def seconds_since(iso_time):
        then = datetime.strptime(iso_time, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
        return (datetime.now(timezone.utc) - then).total_seconds()
    
    # One line per step per visitor, the same names you fire to your pixel.
    def log(event, click, zip_code):
        line = {"click_id": click["click_id"], "event": event,
                "time": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "zip": zip_code}
        with open("events.log", "a") as f:
            f.write(json.dumps(line) + "\n")
    
    def build_handoff_link(click, address):
        parts = urlsplit(TRACKING_LINK)
        query = dict(parse_qsl(parts.query))
        query.update({
            "sub1": click["source"], "sub2": click["click_id"], "sub3": click["ad_id"],
            "street": address.get("street", ""), "city": address.get("city", ""),
            "state": str(address.get("state", "")).upper()[:2], "zip": str(address.get("zip", ""))[:5],
        })
        return urlunsplit(parts._replace(query=urlencode(query)))
    
    class Handler(BaseHTTPRequestHandler):
        def do_POST(self):
            if self.path != "/api/confirm":
                self.send_error(404)
                return
            try:
                body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))))
            except ValueError:
                body = {}
            ip = self.client_address[0]   # behind a proxy, read the address your proxy forwards
            click = clicks.get(body.get("click_id"))
            address = body.get("address") or {}
            if not click or not challenge_passed(body.get("challenge_token")):
                return self.reply(EXIT_URL)
            if is_datacenter_network(ip) or over_rate_limit(ip):
                return self.reply(EXIT_URL)
            if seconds_since(click["time"]) < MIN_SECONDS:
                return self.reply(EXIT_URL)
            click["counted"] = True
            log("address_confirmed", click, str(address.get("zip", ""))[:5])
            self.reply(build_handoff_link(click, address))
    
        def reply(self, go):
            data = json.dumps({"go": go}).encode()
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(data)))
            self.end_headers()
            self.wfile.write(data)
    
    ThreadingHTTPServer(("", int(os.environ.get("PORT", "3000"))), Handler).serve_forever()

    Plain steps

    on POST /api/confirm:
      click = clicks.find(body.click_id)
      if not challenge_passed(body.challenge_token): send to EXIT_URL
      if is_datacenter_network(client_ip): send to EXIT_URL
      if over_rate_limit(client_ip, MAX_PER_HOUR): send to EXIT_URL
      if seconds_since(click.time) < MIN_SECONDS: send to EXIT_URL
      click.counted = true
      log("address_confirmed", click.click_id)
      return build_handoff_link(click, body.address)

    Set MAX_PER_HOUR and MIN_SECONDS yourself, watch the logs for a week, and tune them. Our own confirm pages use the first three rules.

  7. Postback receiver From our experience

    What it does
    Takes the sold-lead postback from your tracking link, finds the click, and sends a sold-lead event to the Meta Conversions API.
    Build it
    Why it exists
    Meta's own lead count can run far above the real leads. Judge and optimize your ads on sales, not on what Meta counts.
    How to build it
    One route. Ignore repeats, because postbacks can be retried. The Meta event shape is on the tracking page.

    JavaScript (Node)

    // Your server. In your tracking account, set the postback URL to
    //   https://YOUR-SERVER.example/postback?click_id=SUB2_MACRO&payout=PAYOUT_MACRO
    // Node 18 or newer: built-in http and fetch, nothing to install.
    var http = require('http');
    
    var clicks = new Map();   // click_id: the record your click logger wrote. Use your database.
    var sent = new Set();     // "click_id:event" already sent to Meta. Use your database.
    
    var META_PIXEL_ID = process.env.META_PIXEL_ID;
    var META_ACCESS_TOKEN = process.env.META_ACCESS_TOKEN;       // keep it on the server
    var META_API_VERSION = process.env.META_API_VERSION;         // the Graph API version Meta lists as current
    var META_TEST_EVENT_CODE = process.env.META_TEST_EVENT_CODE; // set while you test, from Test Events
    
    // One sold-lead event, filled from the click you logged, never from contact details.
    function sendServerEvent(eventName, click, value) {
      var payload = {
        data: [{
          event_name: eventName,
          event_time: Math.floor(Date.now() / 1000),
          event_id: click.click_id + '-sold',
          action_source: 'website',
          event_source_url: process.env.PAGE_URL,   // your pre-lander, for example https://yourbrand.com/check
          user_data: {
            fbc: click.fbc,
            fbp: click.fbp,
            client_ip_address: click.ip,
            client_user_agent: click.user_agent
          },
          custom_data: { value: value, currency: 'USD' }
        }]
      };
      if (META_TEST_EVENT_CODE) payload.test_event_code = META_TEST_EVENT_CODE;
      var url = 'https://graph.facebook.com/' + META_API_VERSION + '/' + META_PIXEL_ID +
        '/events?access_token=' + encodeURIComponent(META_ACCESS_TOKEN);
      return fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload)
      }).then(function (res) {
        if (!res.ok) throw new Error('Meta answered ' + res.status);
      });
    }
    
    http.createServer(function (req, res) {
      var url = new URL(req.url, 'http://localhost');
      if (url.pathname !== '/postback') { res.writeHead(404); return res.end(); }
      res.writeHead(200);   // answer at once; postbacks can be retried, so repeats are ignored below
      res.end('ok');
      var clickId = url.searchParams.get('click_id');
      var click = clicks.get(clickId);
      if (!click) return console.log('unknown click', clickId);
      var key = clickId + ':sold_lead';
      if (sent.has(key)) return;
      sent.add(key);
      var payout = parseFloat(url.searchParams.get('payout')) || 0;
      sendServerEvent('sold_lead', click, payout).catch(function (err) {
        sent.delete(key);   // not sent, so a retried postback may try again
        console.log('Meta send failed', clickId, err.message);
      });
    }).listen(Number(process.env.PORT || 3000));

    Python

    # Your server. In your tracking account, set the postback URL to
    #   https://YOUR-SERVER.example/postback?click_id=SUB2_MACRO&payout=PAYOUT_MACRO
    # Python 3 standard library only.
    import json, os, time
    from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
    from urllib.parse import urlparse, parse_qs, urlencode
    from urllib.request import Request, urlopen
    
    clicks = {}   # click_id: the record your click logger wrote. Use your database.
    sent = set()  # "click_id:event" already sent to Meta. Use your database.
    
    META_PIXEL_ID = os.environ["META_PIXEL_ID"]
    META_ACCESS_TOKEN = os.environ["META_ACCESS_TOKEN"]            # keep it on the server
    META_API_VERSION = os.environ["META_API_VERSION"]              # the Graph API version Meta lists as current
    META_TEST_EVENT_CODE = os.environ.get("META_TEST_EVENT_CODE")  # set while you test, from Test Events
    
    # One sold-lead event, filled from the click you logged, never from contact details.
    def send_server_event(event_name, click, value):
        payload = {"data": [{
            "event_name": event_name,
            "event_time": int(time.time()),
            "event_id": click["click_id"] + "-sold",
            "action_source": "website",
            "event_source_url": os.environ.get("PAGE_URL"),   # your pre-lander, for example https://yourbrand.com/check
            "user_data": {
                "fbc": click.get("fbc"),
                "fbp": click.get("fbp"),
                "client_ip_address": click.get("ip"),
                "client_user_agent": click.get("user_agent"),
            },
            "custom_data": {"value": value, "currency": "USD"},
        }]}
        if META_TEST_EVENT_CODE:
            payload["test_event_code"] = META_TEST_EVENT_CODE
        url = "https://graph.facebook.com/%s/%s/events?%s" % (
            META_API_VERSION, META_PIXEL_ID, urlencode({"access_token": META_ACCESS_TOKEN}))
        req = Request(url, data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"})
        with urlopen(req, timeout=10) as r:   # raises an error unless Meta answers with success
            r.read()
    
    class Handler(BaseHTTPRequestHandler):
        def do_GET(self):
            url = urlparse(self.path)
            if url.path != "/postback":
                self.send_error(404)
                return
            self.send_response(200)   # answer at once; postbacks can be retried, so repeats are ignored below
            self.send_header("Content-Length", "2")
            self.end_headers()
            self.wfile.write(b"ok")
            query = parse_qs(url.query)
            click_id = query.get("click_id", [""])[0]
            click = clicks.get(click_id)
            if not click:
                print("unknown click", click_id)
                return
            key = click_id + ":sold_lead"
            if key in sent:
                return
            sent.add(key)
            try:
                payout = float(query.get("payout", ["0"])[0])
            except ValueError:
                payout = 0.0
            try:
                send_server_event("sold_lead", click, payout)
            except Exception as err:
                sent.discard(key)   # not sent, so a retried postback may try again
                print("Meta send failed", click_id, err)
    
    ThreadingHTTPServer(("", int(os.environ.get("PORT", "3000"))), Handler).serve_forever()

    Plain steps

    on GET /postback?click_id=..&payout=..:
      click = clicks.find(query.click_id)
      if not click: log("unknown click", query.click_id) and stop
      if already_sent(click.click_id, "sold_lead"): stop
      meta.send_server_event("sold_lead", click, value = query.payout)
      mark_sent(click.click_id, "sold_lead")

The hand-off link builder

Your page builds the link from the address the visitor confirmed. Start from your own tracking link, which already carries sub1, sub2 and sub3.

The address the visitor confirmed
Sub IDs

Your hand-off link

https://TRACKING-DOMAIN/AFFCODE/OFFERCODE/?sub1=meta&sub2=YOUR_CLICK_ID&sub3=AD_ID&street=123+Main+St&city=Denver&state=CO&zip=80202
Contact fields: accepted, strongly discouraged

If you already hold the visitor's first name, last name, phone or email, our page accepts them in the link as firstName, lastName, phone and email.

We strongly discourage it. Links end up in browser history, server logs and analytics on both sides, so those details would sit there too. Our page collects them anyway, under our own consent text.

Use a token, not personal details, in links. When you send a visitor to one of your own pages (a text or email link), put a random token in the link, for example ?t=k3J9xQ2mP7vL8wRt. Your server looks the token up and finds the address. Make it random, single-use where you can, at least 16 characters, and let it expire after a few days. Never build it from the data itself.

Build it: the same link in code

JavaScript (browser)

// trackingLink: your tracking link with sub1, sub2 and sub3 already set.
// address: what the visitor confirmed on step 5.
function buildHandoff(trackingLink, address) {
  var url = new URL(trackingLink);
  url.searchParams.set('street', address.street);
  url.searchParams.set('city', address.city);
  url.searchParams.set('state', String(address.state).toUpperCase().slice(0, 2));
  url.searchParams.set('zip', String(address.zip).slice(0, 5));
  if (address.lat && address.lng) {
    url.searchParams.set('lat', String(address.lat));
    url.searchParams.set('lng', String(address.lng));
  }
  // Leave out name, phone and email: our page accepts them, but we strongly discourage it.
  return url.toString();
}

Python

# tracking_link: your tracking link with sub1, sub2 and sub3 already set.
# address: what the visitor confirmed on step 5.
from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode

def build_handoff(tracking_link, address):
    parts = urlsplit(tracking_link)
    query = dict(parse_qsl(parts.query))
    query["street"] = address["street"]
    query["city"] = address["city"]
    query["state"] = str(address["state"]).upper()[:2]
    query["zip"] = str(address["zip"])[:5]
    if address.get("lat") and address.get("lng"):
        query["lat"] = str(address["lat"])
        query["lng"] = str(address["lng"])
    # Leave out name, phone and email: our page accepts them, but we strongly discourage it.
    return urlunsplit(parts._replace(query=urlencode(query)))

Plain steps

start from your tracking link, which already carries sub1, sub2 and sub3
add street and city as the visitor confirmed them
add state as two capital letters, for example CO
add zip as five digits
if your address lookup gave you lat and lng: add them too
leave out name, phone and email (accepted by our page, strongly discouraged)
encode every value, so a space becomes +   (the browser's URL tools do this for you)
send the visitor to the finished link

The link shape and every field are explained on the tracking page.

The events

Use these names so we can talk about the same steps when we compare notes. The names are our recommendation Untested.

EventWho fires itWhen
prelander_viewYouYour pre-lander loads.
qualifiedYouThe visitor passes your qualifying questions.
address_confirmedYouThe visitor taps "Yes, this is my home" and passes the bot filter.
handoffYouYour page sends the visitor to our page.
sold leadOur postbackThe lead sells. It reaches you as a postback with your sub2 and the payout, and your server passes it to Meta.

What we handle for you

Everything that touches the visitor's contact details happens on our page. You do not need to build, buy or host any of it.

  • Consent text. The visitor agrees to be contacted on our page, under our wording.
  • TrustedForm certificate. Captured on our page as the visitor fills in the form.
  • Phone code. We text the visitor a code and check it before the lead goes anywhere.
  • Verification. We check the lead before it is offered to buyers.
  • Delivery. We deliver the lead to buyers who cover the area, and report a sale back through your tracking link.

Want to run the whole form yourself, contact details and all? That is a different arrangement with different terms, and this guide does not cover it. Talk to us.

Done for you

Have us build it for you

Tell us a little about you. We'll email you to take it from there. It's free.

What do you want built?

Prefer email?