Vital Guides

CONNECT CALENDLY TO KEAP WITHOUT ZAPIER
(AND WITHOUT DUPLICATE CONTACTS)

Every tutorial for this says the same thing: match the booking back to a contact by email address. That advice is why your CRM has three records for the same person. Here is the approach that never matches at all, works on the free Calendly plan, and costs nothing per booking.

Updated July 17, 2026· Calendly, Keap· Intermediate· Guide 1 of 3

WHAT GOES WRONG

Someone fills in your form as jane@acmecorp.com. A minute later they book a call, and the calendar on their phone autofills jane.doe@gmail.com. Or they typo it. Or their address is jane+bookings@acmecorp.com and your matcher treats the plus tag as a different person.

Your automation looks for a contact with that email, finds nothing, and creates one. Now there are two records. One has the form answers and the campaign history. The other has the appointment. The follow up sequence fires on the record without the appointment, so the person gets an email asking them to book the call they already booked.

Nothing errored. No integration went red, no alert fired, and the booking really did land in the CRM. It landed on the wrong contact, which is a different problem, and one you find out about weeks later when someone replies asking why you keep chasing them.

THE QUESTION NOBODY ASKS

Why are you matching at all?

You already know who this person is. They filled in your form thirty seconds ago. You have their contact record open in the same browser session. Matching is only necessary because somewhere between the form and the booking, you threw the identity away and are now trying to find them again using a string they typed twice.

The fix is not a smarter matching rule. Fuzzy matching, plus tag stripping, domain fallbacks: they are all attempts to recover something you did not have to lose. The fix is to carry the identity through the booking.

THE APPROACH

Four pieces. The first two are ordinary, the third is the one that matters, and the fourth is where the work happens.

1. Prefill the widget

Initialise Calendly's inline embed with what you already collected. Name, email, and anything else you ask for goes into prefill, with custom questions keyed as a1, a2 and so on in the order they appear on the event type.

Browserillustrative
Calendly.initInlineWidget({
  url: "https://calendly.com/you/your-event",
  parentElement: holder,
  resize: true,
  prefill: {
    name:  fullName,
    email: knownEmail,
    // custom questions, in the order they appear on the event type
    customAnswers: { a1: knownPhone, a2: knownCompany }
  }
});

This is not a convenience feature. Every field they do not retype is a field they cannot typo, which removes most of the mismatches before they happen. It does not remove all of them, because Calendly lets people edit the prefilled email, which is exactly why the next three steps exist.

2. Listen for the booking

The embedded widget posts a message to the parent page when a booking completes. No API, no webhook, no plan requirement: it is the widget talking to the page it is sitting on.

Browserillustrative
function onBooked(e) {
  // Never skip this line. See "the origin check" below.
  if (e.origin !== "https://calendly.com") return;
  if (!e.data || e.data.event !== "calendly.event_scheduled") return;

  window.removeEventListener("message", onBooked);
  // ... post to your own endpoint, step 3
}
window.addEventListener("message", onBooked);

3. Send a signed token, not a contact ID

This is the whole guide in one line.

Browserillustrative
fetch("/api/booked", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  keepalive: true,                  // see "keepalive" below
  body: JSON.stringify({ token: sessionToken })   // NOT contact_id
});

A contact ID sent from the browser is not identity, it is a request. Anyone can open devtools, change 12345 to 12346, and tag a record that is not theirs. If your endpoint accepts a contact ID from the client, your CRM is writable by anyone who can read your JavaScript.

A signed token is different. You minted it server side when the form was submitted, it is tamper evident, and it resolves to exactly one stored record. The browser carries it around without ever being trusted with what it means.

4. Resolve server side, then act

Parse the token, look up the record you stored when the form came in, recover the contact ID from your own storage, and only then talk to Keap.

Serverillustrative
const id  = parseToken(body.token, SIGNING_SECRET);   // invalid -> 400, stop
const rec = await readRecord(id);                       // your storage, your ids
if (!rec || !rec.contact_id) return notFound();

await fetch(
  `https://api.infusionsoft.com/crm/rest/v2/tags/${TAG_BOOKED}/contacts:applyTags`,
  { method: "POST",
    headers: { "X-Keap-API-Key": KEAP_KEY, "Content-Type": "application/json" },
    body: JSON.stringify({ contact_ids: [String(rec.contact_id)] }) }
);

The contact ID never crossed the wire. It came out of your own storage, keyed by a token only your server can validate. There is no matching step, so there is no duplicate.

THREE DETAILS THAT LOOK SMALL

These are the ones that turn a working demo into something you can leave running.

The origin check

window.addEventListener("message", ...) listens to messages from anyone. Any page that can get itself into a frame alongside yours, or any script that calls postMessage, can send { event: "calendly.event_scheduled" } and trigger your endpoint. Checking e.origin === "https://calendly.com" is the difference between an event and a rumour.

keepalive: true

Calendly does its own thing the moment a booking completes, and the page can navigate or unload while your fetch is still in flight. A normal request gets cancelled and the tag silently never lands. keepalive: true tells the browser to finish the request even if the page goes away.

This is the bug you would not find. It works every time on your machine, because your machine is fast and your connection is good. It fails for the client on hotel wifi, occasionally, with no error anywhere. Fire and forget only works if something guarantees the sending.

Clear the recovery field

If you send an abandonment email to people who started booking and stopped, clear whatever drives it the moment they book. Otherwise the person who just scheduled a call gets an email tomorrow asking them to schedule a call, with a link that reopens the scheduler they already used.

This has nothing to do with Calendly and everything to do with automation being honest about state. Booking is not just a thing to record. It is a thing that invalidates other things.

WHAT IT COSTS

WHEN YOU CANNOT DO THIS

Honesty matters more than the trick. This approach depends on a browser session: the person is on your page, you know who they are, and the token is right there.

Sometimes there is no session. Someone books from a link in an email three days later. Someone forwards the link to a colleague. The recap arrives long after the tab is closed. In those cases you have no token, email is the only identifier that exists, and matching on email is correct.

So the rule is not "never match on email." It is: carry a token when you have a session, match on email when you do not, and know which one you are in. Most duplicate contact problems are someone matching on email in the first case, where they did not have to.

COMMON QUESTIONS

Why not just use Zapier?
You can, and for a simple setup it is fine. The problem is not Zapier, it is what the Zap has to do: match the booking back to a contact by email. That match is where the duplicates come from, and no amount of Zap configuration fixes it. Zapier also charges per task, and a booking is a task every time.
Why not use Calendly webhooks?
Because of when they fire. A webhook reaches you from Calendly's servers with a payload that identifies the invitee by email, which puts you straight back into matching. The embed event fires while the person is still on your page, which is the one moment you still know exactly who they are. Webhooks also need a paid Calendly plan; this does not.
Does this really work on the free plan?
Yes. No API, no personal access token, no webhook subscription. The inline embed and its browser event are all this uses.
What if they book without filling in the form first?
Then there is no token and email is all you have, so match on email. That is not a failure of the approach, it is a different situation with a different correct answer. Guide 2 is entirely about that case.
Is it safe to trigger a CRM write from the browser?
Only if the browser cannot choose the target. Never accept a contact ID from the client. Send a signed token, resolve the contact on the server, and check the message origin. Do those three and the browser is just a courier carrying a sealed envelope.
Will this work with a CRM other than Keap?
Yes. Nothing in the pattern is Keap specific. The Calendly half is identical and only the last API call changes. The underlying idea, carry identity forward instead of reconstructing it, applies anywhere a booking has to land on a record that already exists.
WANT THIS BUILT, NOT READ ABOUT?

We have been doing Keap integrations since 2010, across more than fifty platforms. If your CRM already has duplicate contacts from a booking integration, that is a fixable afternoon, not a rebuild.

Book a Free Consult
The rest of the loop
01Calendly to Keap without duplicates· you are here