Powered by FusionSync AI
n8n Hub logon8nAutomationHub
ImplementationSlack

Cal.com meeting reminders in Slack: nudge your team 3 times before every booking

Build an n8n workflow that catches every Cal.com booking, logs it to Google Sheets, and pings Slack 1.5h, 1h, and 30 min before the call so no lead goes unattended.

n8n Hub Team9 min read

A booked meeting is only useful if someone actually shows up prepared. The failure mode is boring and expensive: a lead books a call, the confirmation email gets buried, and 30 minutes before the meeting nobody on your side remembers it exists. The lead joins an empty room, or your AE scrambles in with zero context.

This guide builds the fix in n8n. Every Cal.com booking gets logged to a sheet, your team gets an instant "new booking" ping, and then Slack nudges them again 1.5 hours, 1 hour, and 30 minutes before the call. By the time the meeting starts, it has been impossible to forget.

Available resources#

This build uses two assets you will set up below:

  1. n8n workflow "Meeting Follow-up On Slack" (the capture flow plus the reminder scheduler).
  2. Google Sheet "Meetings" (the reminder queue and audit log).

What you'll need#

Before you begin, make sure you have:

  1. An n8n account (cloud or self-hosted) with the workflow editor.
  2. A Cal.com account with admin access to the event type you want to track, so you can add a webhook.
  3. A Slack workspace with a channel for the alerts (this guide uses #meeting-updates) and an n8n Slack credential allowed to post to it.
  4. A Google account with a sheet n8n can read and write via a Google Sheets OAuth credential.

Overview of the automation#

The automation runs in two phases, sharing one Google Sheet:

  1. Capture. A Cal.com webhook fires on every new booking. n8n posts a "new meeting booked" message to Slack and writes three reminder rows to the sheet, one per follow-up time.
  2. Remind. A scheduler runs every 15 minutes, finds reminders whose time has passed and are still Pending, posts them to Slack, and marks them Done.
1Cal.com

Booking Created event for one specific event type.

2n8n (capture)

Normalize the payload, post the booking to Slack, write 3 reminder rows.

3Google Sheets

One row per reminder: due time + Pending/Done status.

every 15 min
4n8n (scheduler)

Read Pending rows, keep the ones now due, send, mark Done.

  • Slack: instant booking alert + 3 timed reminders
  • Google Sheets: reminder queue and audit log
Booking is captured once; reminders fire on a clock from the sheet.

Splitting capture from delivery is the important design decision. The booking happens once; the reminders need to fire on a clock. Doing both in one trigger-driven flow is how people end up abusing Wait nodes that silently die on a server restart.

Step-by-step setup#

1. Set up the Google Sheet#

Create a sheet (the example names it "Meetings") with these exact column headers:

Lead_Email | Lead_Name | Lead_Message | MeetingTime | MeetigLink | Follow_UP 1 | Status_FU_1

2. Set up the Slack channel#

Create the channel that will receive everything (the example uses #meeting-updates) and connect a Slack credential in n8n with permission to post there. You will reference this same channel in both the booking message and the reminder message.

3. Set up the Cal.com webhook#

In Cal.com, add a webhook on the event type you want to track:

  1. Subscribe to the Booking Created trigger.
  2. Set the URL to your n8n Webhook node URL.
  3. Save, then send a test booking so you have a real payload to map against.

4. Build the capture workflow#

Receive the webhook. Add a Webhook node (POST). Cal.com sends a large payload; pull only the useful fields into clean names with a Set node named GLOBAL:

bookingId        = {{ $json.body.payload.bookingId }}
name             = {{ $json.body.payload.responses.name.value }}
email            = {{ $json.body.payload.responses.email.value }}
title            = {{ $json.body.payload.title }}
startTime        = {{ $json.body.payload.startTime }}
additionalNotes  = {{ $json.body.payload.additionalNotes }}
eventTypeId      = {{ $json.body.payload.eventTypeId }}
videoCallUrl     = {{ $json.body.payload.metadata.videoCallUrl }}
triggerEvent     = {{ $json.body.triggerEvent }}

Naming this node GLOBAL matters later: the follow-up code node reaches back to it by name to read startTime.

Filter to the right event. Cal.com also fires webhooks for reschedules and cancellations. A Filter node keeps only fresh bookings:

{{ $json.triggerEvent }}  equals  BOOKING_CREATED

Announce the booking in Slack. A Slack node (Send Message) posts the first notification (not a reminder, just "a lead booked a call"), formatted for humans:

๐Ÿš€ New Meeting Booked
 
๐Ÿ‘ค Lead Name: {{ $json.name }}
๐Ÿ“ง Email: {{ $json.email }}
 
๐Ÿ’ฌ Notes:
{{ $json.additionalNotes || 'No additional notes provided.' }}
 
๐Ÿ“… Meeting Time:
{{ DateTime.fromISO($json.startTime).setZone('Asia/Kolkata').toFormat('dd MMM yyyy, hh a ZZZZ') }}
 
๐Ÿ”— Meeting Link:
{{ $json.videoCallUrl }}

The DateTime.fromISO(...).setZone(...) conversion is doing real work. Cal.com sends UTC; your team thinks in their local timezone. Convert once here so nobody does mental arithmetic at 30 minutes' notice.

Generate the three follow-up times. A Code node reads the booking start time and returns three items, one per reminder:

const item = $('GLOBAL').first().json;
 
const meetingTime = new Date(item.startTime);
 
const followUp1 = new Date(meetingTime);
followUp1.setUTCMinutes(followUp1.getUTCMinutes() - 90); // 1.5 hours
 
const followUp2 = new Date(meetingTime);
followUp2.setUTCMinutes(followUp2.getUTCMinutes() - 60); // 1 hour
 
const followUp3 = new Date(meetingTime);
followUp3.setUTCMinutes(followUp3.getUTCMinutes() - 30); // 30 min
 
return [
  { json: { ...item, Follow_Up_Number: 1, Follow_Up_Time: followUp1.toISOString(), Status: 'Pending' } },
  { json: { ...item, Follow_Up_Number: 2, Follow_Up_Time: followUp2.toISOString(), Status: 'Pending' } },
  { json: { ...item, Follow_Up_Number: 3, Follow_Up_Time: followUp3.toISOString(), Status: 'Pending' } },
];

The mental model worth internalizing: one meeting becomes three rows, each carrying its own due time and status. You are not storing one meeting with three columns; you are storing three independent reminders that share a lead. That makes the scheduler trivially simple: it never has to know "which reminder is this," it just asks "is this row due yet?"

Write the reminders to the sheet. A Google Sheets node (Append) writes each item as a row:

Lead_Name   = {{ $json.name }}
Lead_Email  = {{ $json.email }}
Lead_Message= {{ $json.additionalNotes }}
MeetingTime = {{ $json.startTime }}
MeetigLink  = {{ $json.videoCallUrl }}
Follow_UP 1 = {{ DateTime.fromISO($json.Follow_Up_Time).toISO() }}
Status_FU_1 = Pending

After a booking, the sheet has three Pending rows for that lead at 90, 60, and 30 minutes before the meeting. The capture flow is done; it never thinks about timing again.

5. Build the reminder workflow#

Poll on a schedule. A Schedule Trigger runs every 15 minutes. That is your reminder resolution: a follow-up fires within 15 minutes of its scheduled time, which is fine for these nudges. There is no Wait node holding state, so polling more often just costs a few extra sheet reads.

Pull the pending reminders. A Google Sheets node (Get Rows) filtered to Status_FU_1 = Pending returns every unsent reminder across all leads, not just the latest one.

Decide what is due. A Code node stamps each row with whether its time has arrived:

const now = new Date().getTime();
 
return $input.all().map(item => {
  const followUpTime = new Date(item.json['Follow_UP 1']).getTime();
  return {
    json: {
      ...item.json,
      row: item.json.row_number,
      isPending: item.json['Status_FU_1'] === 'Pending',
      isPast: followUpTime <= now,
      diff_minutes: Math.round((now - followUpTime) / 60000),
    },
  };
});

A Filter node then keeps only rows where isPast is true.

Send and mark done, in a loop. Run the due rows through a Loop Over Items (split in batches). For each one:

  1. A Set node re-flattens the row into clean names (Lead_Name, Lead_Email, MeetingTime, MeetigLink, Follow_UP 1).
  2. A Slack node posts the reminder into the same channel, reusing the booking message format.
  3. A Google Sheets node (Update) sets Status_FU_1 = Done, matching on the Follow_UP 1 value so it updates the exact row that just fired.

Testing the workflow#

Validate it end to end before trusting it with real leads:

  1. Fire a booking. Create a test booking on your Cal.com event type. Confirm the #meeting-updates channel gets the "New Meeting Booked" message within seconds.
  2. Check the sheet. Confirm three new rows appeared for that lead, all Pending, with Follow_UP 1 times at 90, 60, and 30 minutes before the meeting.
  3. Force a due reminder. Edit one row's Follow_UP 1 to a time in the past, then either wait for the 15-minute schedule or run the reminder workflow manually.
  4. Confirm delivery and status. That reminder should post to Slack, and its Status_FU_1 should flip to Done. Run it again and confirm the same row does not fire twice.

If a reminder fires twice, your Update step is not matching the right row. Check that Follow_UP 1 values are unique enough to match on, or switch the match to the sheet's row number.

Customization options#

  • Change the timings. Edit the three setUTCMinutes(... - N) lines in the follow-up code node to whatever cadence you want (for example 24h, 2h, 15min).
  • Route by event type. Use eventTypeId to send different event types to different Slack channels with a small lookup, so demos and discovery calls do not land in the same place.
  • Tag the assignee. Pull the owner from a rota sheet and add a Slack mention to the reminder so a specific person is on the hook.
  • Tighten resolution. Drop the schedule to every 5 minutes if you want reminders closer to their exact target time.
  • Add a cancel guard. Subscribe to Cal.com's cancellation webhook and mark the matching rows Done (or Cancelled) so reminders stop for meetings that no longer exist.

Common mistakes that quietly break this#

  • One row per meeting instead of one per reminder. Three status columns force branching logic into the scheduler. One row per reminder collapses everything into a single "is it past and pending?" check.
  • Leaning on Wait nodes for timing. A booking made a week out means an execution sitting open for seven days, vulnerable to restarts and timeouts. The sheet-plus-scheduler pattern keeps state in the sheet, so it survives all of that.
  • Forgetting the timezone conversion. Cal.com timestamps are UTC. Post them raw and your team reads "05:30" for an 11:00 AM IST call. Convert in the Slack message.
  • Not filtering the trigger event. Without the BOOKING_CREATED filter, reschedules and cancellations also create reminders for meetings that no longer exist.

Conclusion#

You now have a booking pipeline that refuses to let a meeting approach quietly: an instant Slack alert when a lead books, three escalating reminders before the call, and a Google Sheet that doubles as an audit log of every nudge. Wire the capture flow to your Cal.com event type, point the scheduler at the same sheet, and the next booking that lands will announce itself, then keep nudging right up until the call begins.