Back to all articles
Architecture

One vocabulary for every lead source

FT
Formwork Team
Architecture
Jun 30, 2026 · 8 min read

Here is the same piece of information, arriving from four places on the same afternoon.

text
1Website form      phone_number          "(555) 123-4567"
2Facebook          field_data[phone_number]   ["+15551234567"]
3TikTok            phone                 "555 123 4567"
4Partner webhook   contact.Phone         "5551234567"

Four names, four shapes, one fact. Now write a rule that only sends leads with a valid mobile number to your premium buyer, and have it work regardless of which of those four the lead came from.

You can write it four times. Most systems that grow organically do exactly that, and it is why adding a fifth source is a week of work rather than an afternoon.

Normalise on the way in

The alternative is to pick a name — phone — and make every source translate into it before anything downstream ever sees the lead.

text
Source-specific names  →  [ mapping ]  →  canonical fields  →  everything else

After that boundary, nothing in the system knows or cares where a lead came from. Conditions test phone. Buyer templates interpolate {{phone}}. Conversion events hash phone. Add a fifth source and you configure one mapping; every rule you have ever written works on it immediately.

This is the single decision that determines whether a lead platform scales past its second integration.

Normalisation is more than renaming

Renaming contact.Phone to phone is the easy half. The harder half is that those four values above are not the same string, and a rule like phone is not empty will pass on all of them while phone starts with +1 passes on exactly one.

So a mapping needs to do three things per field:

Extract the value, wherever it lives. That means walking a dotted path like contact.Phone, indexing into an array like items[0].sku, and — the awkward one — looking up a named entry inside an array of {name, values} objects, which is how Facebook and several CRMs represent a form's answers.

That last case deserves emphasis. It is tempting to map Facebook's answers by position: field_data[0] is the phone, field_data[1] is the state. Meta does not guarantee the order of those entries. An index-based mapping will start silently pointing at a different question, and nothing will error — you will just begin selling leads with a state in the phone field.

Transform it, in a defined order. Trim the whitespace, take the first value out of the array, strip the punctuation, format to E.164. Order is load-bearing: run the phone formatter before you have unwrapped the array and it is handed a list, not a number, and gives up.

Coerce it to a type. "42" becomes the number 42 so greater_than works. "Texas" becomes "TX" so a list of state codes matches. "yes" becomes true so a consent check behaves.

Fail soft, and say so

A mapping will go wrong. The buyer added a question, the partner changed a key, somebody typed their phone number into the name field.

The wrong response is to throw. A lead that fails to map is still a lead, and refusing to store it because one of nine fields could not be coerced turns a small mapping problem into lost revenue.

The right response is to omit the field, record a warning against the lead, and carry on. The lead is stored, the other eight fields are fine, and there is a visible trail explaining why one is missing. When someone eventually asks why the premium buyer stopped taking anything, the answer is sitting on every affected lead rather than in a log file nobody kept.

Keep the raw payload forever

Normalised data is a lossy projection. It contains what you decided to keep, shaped the way you decided to shape it, using the mapping that was configured at the time.

Store what actually arrived, unmodified, alongside it. It costs almost nothing and it is the only thing that answers:

  • Why is this field empty? — because it arrived under a name your mapping did not know.
  • Did the buyer change their payload? — compare last month's raw lead to this month's.
  • Can we re-map historical leads now the mapping is fixed? — only if you kept the originals.

Every operator who has not kept raw payloads discovers this during the incident where they need them.

Check the contract before it bites

Once conditions and buyer templates are written against canonical keys, each of them is a dependency. A condition testing state needs state to arrive.

If it does not, the rule does not error. It is simply false, forever, for every lead from that source — which quietly routes all of them to your default. That is a failure with no symptom except a number that is slightly wrong in a report nobody reads weekly.

So compute the contract: the set of canonical keys referenced by everything in a piece of routing, compared against the set each attached source actually produces. Check it when the source is attached, not when the lead arrives. The gap is trivial to fix at configuration time and expensive to notice at runtime.

Pick the names once. Translate at the edge. Everything after that gets simpler.