$1M+ in closed cash traced to the funnels that produced it$1M+ traced to sourceBook a call →
lead trackingdata joinspayments

Why your booking tool, CRM and payments disagree about the same lead

By Taziem Uddin · · 7 min read
ONE PERSONThree spellings, one keyBOOKINGc@home.exampleCRMc@home.examplePAYMENTc@work.examplePERSON KEYc@home.example+ alias c@work.example

The first payments export we pasted into our own CRM had no payer email and no reference number. Each line held a date, an amount, a status and a description. The description ended in a person's name, and the name was ours.

The platform writes the recipient at the end of that line. A matcher that read the name as the customer would have filed every client's money under the one person receiving it, and the report would have shown a single very good customer.

That export is the extreme case of a common problem. A lead is one person, and three tools hold a record of them: the scheduler that took the booking, the CRM that holds the contact, and the system that took the money. Nobody told the three that their records belong together. Each has its own name for the person, usually an email address typed by hand at a different moment.

What a split record costs

The owner asks which channel brought the cash. The report can only answer for payments it can place, so a payment it cannot place is counted as "unknown" or dropped. The channel with the most unplaced payments looks worse than it is, and the budget moves away from it.

The same split sends a client who has paid the reminders and the sequence meant for a prospect. It inflates the count of unique people, because one buyer is two contacts. It takes a setter's credit away, because the booking sits on a contact the setter never worked. Every number downstream of the join inherits the error: cash by source, show rate, close rate. The trail post follows a sale from the ad to the payment. This one is about the key that holds the trail together.

What each tool calls the person

The booking tool. Calendly's invitee record carries an email, a name, a time zone "to use when displaying time to the invitee", and a creation time in UTC (Calendly API specification). The email is the one on the booking. A site can fill the name and email in ahead of time through the advanced embed (Calendly help center), but what the scheduler reports afterwards is the address it holds, and your own form's copy of that address is a different record.

The CRM. HighLevel has a setting called Allow Duplicate Contact. With it on, "new records are allowed even if an email or phone already exists". With it off, "matching email or phone details update an existing contact instead of creating a new one". You choose email or phone as the primary lookup, with an optional secondary field checked when the first finds nothing (HighLevel help center). So the CRM's idea of the same person is a setting, and it can differ from account to account.

The payment. By default, Stripe Checkout creates a Customer only when the session needs one; sessions that do not are grouped as guest customers. A customer_email you pass is used when the Customer is created, and without it the buyer is asked to type an address (Stripe API reference). The payer's email is whatever was typed on the checkout page, or whatever the payout platform chose to export. In our case it chose nothing.

Then there is the mailbox itself. Google says that for a personal Gmail address, "emails sent to any dotted version of your address will only go to you" (Gmail help). Two spellings reach one inbox, and two systems will each hold their own string for it.

Four ways one person becomes two

  1. A second address. A work email at checkout, a personal one at the booking, or the same Gmail with a dot in a different place.
  2. No address at all. A payout export that names the recipient and leaves the payer out, as above.
  3. A duplicate made on purpose. A CRM set to allow duplicates will create a second contact when a form arrives with the same email.
  4. Different clocks. The booking carries the invitee's time zone and a UTC stamp, Stripe records time as seconds since the Unix epoch (Stripe customer object), and the report counts days in one zone. A payment made late in the evening on one side of the world is the next day on the other. A match on "the same day" or "within a few minutes" works only after every stamp is converted to one zone. We fix one reporting zone per build and print it under the number.
  1. 1
    Booking tool
    Names the buyer
    c@home.example
    Stamps the time in
    the invitee's zone, and a UTC creation time
  2. 2
    CRM
    Names the buyer
    c@home.example, one contact
    Stamps the time in
    the account's zone
  3. 3
    Payment
    Names the buyer
    c@work.example
    Stamps the time in
    seconds since the Unix epoch
  4. Key
    The joined person
    Names the buyer
    c@home.example, with c@work.example saved as an alias
    Stamps the time in
    one reporting zone, printed under the number
The same buyer as three tools record that buyer. A made-up example.

The key

The rule is short. Carry an id wherever a tool lets you. Match on typed strings only where it does not. When you must match a string, write each resolution down once so nobody does it twice.

On our own site, the booking joins the visit on an id. The visitor's browser posts a "booking completed" event carrying the Calendly invitee URI the moment the scheduler confirms. The booking itself arrives separately, from a signed webhook, and the two land in either order. The join runs on whichever side arrives second. Before we ran it on both paths, a booking made at 10:00 sat without its visitor, landing page and button until the next daily run, and the journeys view called that session a lead instead of a booking for the same stretch. The fix was to run the join when each side lands.

For people, the key is the first email, lower-cased, for good. When someone changes their email on a form, the new address is saved as an alternate on the same contact and becomes the preferred one for outreach. When two contacts are merged, the merged-away address becomes an alias of the survivor, so every past and future record under it lands on the same person.

For payments, carry an id in if the tool has a slot. Stripe's client_reference_id is "a unique string to reference the Checkout Session" that "can be used to reconcile the session with your internal systems" (Stripe API reference). Put the contact's id there when the session is created and the payment arrives already joined.

Where the platform gives you no slot, match in tiers:

  1. Exact. The payer string equals a contact's email or a stored alias, and the amount fits one of that person's open installments within 1%. The payment is marked verified.
  2. Expected. The payer is unknown, but exactly one open expected payment has that exact amount and a date within 30 days. The match is proposed and one tap confirms it.
  3. Everything else goes to a queue. Resolving a line by hand writes the payer string as an alias on that contact, so the same payer matches by itself from then on. A known payer whose amount fits nothing goes to the queue with their name attached, because the person is certain and only the amount is not.

An installment can be matched once per batch, so a repeated export cannot count it twice.

A worked example

This is a made-up example. It names no client.

Four people book a call and later pay one equal payment each. Two arrived through an Instagram link and two through an email.

BuyerBooked withPaid withJoins on email aloneSource
Aa@home.examplea@home.exampleyesInstagram
Bb.name@gmail.examplebname@gmail.exampleno, a dotInstagram
Cc@home.examplec@work.exampleno, another addressEmail
Dd@home.exampled@home.exampleyesEmail

Joined on email alone, the cash report reads Instagram one payment, email one payment, unknown two. The owner sees half the cash with no source and trims a budget on thin evidence. After B's and C's payer addresses are saved as aliases, the report reads Instagram two, email two, unknown none. Nothing about the buyers changed. The records were always one person each, and the report just did not know it.

The unplaced pair is the number to watch. It is a count of payments with no contact attached, and it should trend to zero.

What to do this week

  1. Export last month's payments and count how many carry an email that matches a CRM contact exactly. The rest are your split records.
  2. Open your CRM's duplicate setting and write down what it matches on.
  3. Decide the person key (the first email, lower-cased) and where alternates and aliases live.
  4. Where your payment tool has a reference field, pass the contact's id into it.
  5. Pick one reporting time zone and print it under every daily number.
  6. Test it yourself: book under one address, pay under another, and see where the trail splits.

If you want to see which of these you can answer today, the Revenue Blind-Spot Score takes about a minute.

The export that opened this post had no payer on it, so a person had to say which contact each payer string belonged to. They did it once per payer. Each answer became an alias, and every later payment from that payer matched itself.

More from the blog

Ready to see this running on your own numbers?

Book a call →
Checking available times…