NAV
API Reference
Shell Ruby Python JavaScript

Introduction

About TimelyDo

TimelyDo is a scheduling platform. You publish a booking page, share the link, and people pick a time from your real availability. No back and forth emails, and no double bookings.

TimelyDo syncs with Google Calendar, so events already on your calendar block those time slots. It connects to Zoom and Google Meet for the meetings people book with you.

Using TimelyDo is free. Learn more on the features pages or in the Help Center.

About the API

The TimelyDo API lets you work with your TimelyDo account from your own applications, scripts and integrations. It uses the same data you see in the app, so anything you read or change through the API shows up in TimelyDo straight away.

Need help? Email support@timelydo.com.

Every example on this page comes in Shell (curl), Ruby, Python and JavaScript. Switch languages with the tabs at the top right.

Base URL

All endpoints live under:

https://timelydo.com/api/v1

Response format

Every response, success or failure, has the same shape:

{
  "success": true,
  "message": "user details",
  "data": {},
  "status": 200
}

Requests and responses are JSON. Every response carries the same four fields:

Field Type Description
success boolean true when the request worked, false otherwise.
message string A short human readable description of the result.
data object or array The payload. An empty object when there is nothing to return.
status integer or string Mirrors the HTTP status. Successful responses use the number (200). Errors use the status name ("unauthorized", "forbidden").

Always check the HTTP status code or success before reading data.

Authentication

The TimelyDo API uses API keys. Every request must send your key in the X-API-KEY header. Requests without a valid key are rejected with 401 Unauthorized.

Get your API key

Every TimelyDo account has one API key, created when the account is created. To find it:

  1. Sign in at timelydo.com.
  2. Open Settings, then Developers Console, then API Key. You can also go straight to timelydo.com/settings/developers/api_key.
  3. Click the copy button next to Your API Key.

Keys are 73 characters long. They do not expire. A key keeps working until you regenerate it or delete your account.

The examples on this page read the key from an environment variable called TIMELYDO_API_KEY, so it never appears in your code:

export TIMELYDO_API_KEY="paste your key here"

Authenticate your requests

curl "https://timelydo.com/api/v1/user" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json"
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/user')

request = Net::HTTP::Get.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  puts "Authenticated as #{body['data']['email']}"
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.get(
    "https://timelydo.com/api/v1/user",
    headers={
        "X-API-KEY": os.environ["TIMELYDO_API_KEY"],
        "Accept": "application/json",
    },
    timeout=10,
)

body = response.json()

if body["success"]:
    print("Authenticated as", body["data"]["email"])
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer (built in fetch). Run this on your server, never in a browser.
async function getCurrentUser() {
  const response = await fetch("https://timelydo.com/api/v1/user", {
    headers: {
      "X-API-KEY": process.env.TIMELYDO_API_KEY,
      "Accept": "application/json",
    },
  });

  const body = await response.json();

  if (body.success) {
    console.log(`Authenticated as ${body.data.email}`);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

getCurrentUser();

A valid key returns the account it belongs to (trimmed):

{
  "success": true,
  "message": "user details",
  "data": {
    "id": "3f6c2a9e-8b1d-4c7a-9e21-5d0b7f4a1c33",
    "email": "jane@example.com",
    "full_name": "Jane Doe",
    "url": "jane",
    "avatar_name": "JD",
    "timezone": "Berlin",
    "date_timezone": "Europe/Berlin",
    "locale": "en",
    "time_format": "12_hours",
    "date_format": "31/12/2024",
    "currency": "eur",
    "country_iso_code": "de",
    "api_key": "YOUR_API_KEY",
    "created_at": "2026-08-09T04:23:16.260Z"
  },
  "status": 200
}

A missing or wrong key returns 401:

{
  "success": false,
  "message": "Invalid api_key",
  "data": {},
  "status": "unauthorized"
}

Send the key in the X-API-KEY header on every request:

X-API-KEY: YOUR_API_KEY

The header name is not case sensitive. The key itself must match exactly.

The quickest way to check a key is GET /api/v1/user. It returns the account the key belongs to.

HTTP Request

GET https://timelydo.com/api/v1/user

Authentication errors

HTTP status message Cause
401 api_key is missing No X-API-KEY header was sent.
401 Invalid api_key The key does not match any active account. It may have been regenerated, or the account was deleted.
403 depends on the endpoint The endpoint only works from a signed in browser session. See below.

Endpoints that need a signed in session

A few account security settings cannot be changed with an API key, even a valid one. This protects your account if a key ever leaks. These endpoints return 403 Forbidden for API key requests:

Endpoint 403 message
/api/v1/two_factor (all methods) and /api/v1/two_factor/backup_codes Sign in to the app to manage two factor authentication.
PUT /api/v1/users/change_password and PUT /api/v1/user/destroy_me Sign in to the app to change your password or delete your account.
/api/v1/hellobar and its actions A signed in session is required

Manage these from the TimelyDo app instead.

Regenerate your API key

Regenerate your key if it may have leaked, or to rotate it on a schedule. The old key stops working immediately, so update every integration that uses it right away.

You can regenerate the key in two ways.

In the app

  1. Open Settings, then Developers Console, then API Key.
  2. Click Regenerate key.
  3. Copy the new key shown on the page.

With the API

curl -X PUT "https://timelydo.com/api/v1/users/regenerate_api_key" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json"
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/users/regenerate_api_key')

request = Net::HTTP::Put.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  new_api_key = body['data']['api_key']
  # Save new_api_key somewhere safe now. The old key has already stopped working.
  puts "New key received (#{new_api_key.length} characters)"
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.put(
    "https://timelydo.com/api/v1/users/regenerate_api_key",
    headers={
        "X-API-KEY": os.environ["TIMELYDO_API_KEY"],
        "Accept": "application/json",
    },
    timeout=10,
)

body = response.json()

if body["success"]:
    new_api_key = body["data"]["api_key"]
    # Save new_api_key somewhere safe now. The old key has already stopped working.
    print(f"New key received ({len(new_api_key)} characters)")
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer (built in fetch). Run this on your server, never in a browser.
async function regenerateApiKey() {
  const response = await fetch("https://timelydo.com/api/v1/users/regenerate_api_key", {
    method: "PUT",
    headers: {
      "X-API-KEY": process.env.TIMELYDO_API_KEY,
      "Accept": "application/json",
    },
  });

  const body = await response.json();

  if (body.success) {
    const newApiKey = body.data.api_key;
    // Save newApiKey somewhere safe now. The old key has already stopped working.
    console.log(`New key received (${newApiKey.length} characters)`);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

regenerateApiKey();

The response contains the new key:

{
  "success": true,
  "message": "api_key is updated",
  "data": {
    "api_key": "YOUR_NEW_API_KEY"
  },
  "status": 200
}

Authenticate this request with your current key. The response returns the new key, and from that moment only the new key works.

HTTP Request

PUT https://timelydo.com/api/v1/users/regenerate_api_key

This endpoint takes no parameters.

If you lost your key

You cannot get a key back through the API without a working key. Sign in to TimelyDo and copy it from the API Key page, or regenerate a new one there.

User

Read and update the account your API key belongs to.

The user object

A user object (trimmed):

{
  "id": "3f6c2a9e-8b1d-4c7a-9e21-5d0b7f4a1c33",
  "email": "jane@example.com",
  "full_name": "Jane Doe",
  "url": "jane",
  "avatar_name": "JD",
  "bio": "Career coach helping people change jobs with confidence.",
  "timezone": "Berlin",
  "date_timezone": "Europe/Berlin",
  "time_format": "12_hours",
  "date_format": "31/12/2024",
  "locale": "en",
  "currency": "eur",
  "country_iso_code": "de",
  "calendar_view": "week",
  "dob": null,
  "show_custom_policy": false,
  "created_at": "2026-08-09T04:23:15.560Z",
  "api_key": "YOUR_API_KEY"
}
Field Type Description
id string Unique id of the account.
email string Sign in email address.
full_name string Name shown on your booking pages.
url string Your booking page address: https://timelydo.com/<url>.
avatar_name string Initials used when there is no profile picture.
bio string or null Short introduction shown on your profile.
timezone string Your time zone, as a TimelyDo time zone name such as Berlin or Eastern Time (US & Canada).
date_timezone string The same time zone as a standard IANA name, such as Europe/Berlin.
time_format string 12_hours or 24_hours.
date_format string How dates are written. See Update the current user.
locale string Language of the app and your emails.
currency string Lowercase ISO currency code, such as usd or eur.
country_iso_code string or null Lowercase two letter country code.
calendar_view string Default calendar view: day, week or month.
created_at string When the account was created, in ISO 8601 UTC.
api_key string Your API key. Treat every response that contains it as secret.

Get the current user

curl "https://timelydo.com/api/v1/user" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json"
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/user')

request = Net::HTTP::Get.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  puts "#{body['data']['full_name']} (#{body['data']['timezone']})"
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.get(
    "https://timelydo.com/api/v1/user",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    timeout=10,
)

body = response.json()

if body["success"]:
    print(body["data"]["full_name"], body["data"]["timezone"])
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function getCurrentUser() {
  const response = await fetch("https://timelydo.com/api/v1/user", {
    headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json" },
  });

  const body = await response.json();

  if (body.success) {
    console.log(`${body.data.full_name} (${body.data.timezone})`);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

getCurrentUser();

Returns the user object for the account the API key belongs to.

HTTP Request

GET https://timelydo.com/api/v1/user

Get account settings

curl "https://timelydo.com/api/v1/user/settings" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json"
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/user/settings')

request = Net::HTTP::Get.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  settings = body['data']
  puts "Week starts on day #{settings['start_day_of_week']}, verified: #{settings['verified']}"
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.get(
    "https://timelydo.com/api/v1/user/settings",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    timeout=10,
)

body = response.json()

if body["success"]:
    settings = body["data"]
    print("Week starts on day", settings["start_day_of_week"], "verified:", settings["verified"])
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function getSettings() {
  const response = await fetch("https://timelydo.com/api/v1/user/settings", {
    headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json" },
  });

  const body = await response.json();

  if (body.success) {
    console.log(`Week starts on day ${body.data.start_day_of_week}, verified: ${body.data.verified}`);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

getSettings();

Settings add these fields to the user object (trimmed):

{
  "start_day_of_week": 1,
  "designation": "Career coach",
  "label_for_primary_timezone": null,
  "verified": false,
  "need_to_set_password": false,
  "show_message_button_on_profile": true,
  "show_message_button_on_events": true,
  "enable_branding": false,
  "profile_picture_url": "https://timelydo.com/rails/active_storage/...",
  "profile_embed_url": "https://timelydo.com/embed/jane",
  "active_policy": { "id": "...", "content": "<p>Booking policy</p>" },
  "branding": null,
  "social_library": null
}

Returns everything in the user object, plus the settings that shape your profile and booking pages: your profile picture, verification status, booking policy, branding and social links.

Use this endpoint when you need profile_picture_url. The plain user object does not include it.

HTTP Request

GET https://timelydo.com/api/v1/user/settings

Update the current user

curl -X PATCH "https://timelydo.com/api/v1/user" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"user": {"time_format": "24_hours", "date_format": "2023-12-31"}}'
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/user')

request = Net::HTTP::Patch.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request.body = { user: { time_format: '24_hours', date_format: '2023-12-31' } }.to_json

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  puts "Time format is now #{body['data']['time_format']}"
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.patch(
    "https://timelydo.com/api/v1/user",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    json={"user": {"time_format": "24_hours", "date_format": "2023-12-31"}},
    timeout=10,
)

body = response.json()

if body["success"]:
    print("Time format is now", body["data"]["time_format"])
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function updateUser() {
  const response = await fetch("https://timelydo.com/api/v1/user", {
    method: "PATCH",
    headers: {
      "X-API-KEY": process.env.TIMELYDO_API_KEY,
      "Accept": "application/json",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ user: { time_format: "24_hours", date_format: "2023-12-31" } }),
  });

  const body = await response.json();

  if (body.success) {
    console.log(`Time format is now ${body.data.time_format}`);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

updateUser();

A value that is not allowed returns 422:

{
  "success": false,
  "message": "Timezone is not included in the list",
  "data": {},
  "status": "unprocessable_entity"
}

Updates the fields you send and leaves everything else as it is. Send the fields inside a user object. Returns the updated user object.

HTTP Request

PATCH https://timelydo.com/api/v1/user

Body parameters

Parameter Allowed values
full_name Required once set. Up to 80 characters.
url 2 to 80 characters. Must be unique, and cannot start with http, https or /, or contain ., ? or #. Check it first.
bio Up to 500 characters.
designation Up to 180 characters.
timezone A TimelyDo time zone name, such as Berlin, Karachi or Eastern Time (US & Canada). Standard names like Europe/Berlin are not accepted here.
time_format 12_hours or 24_hours.
date_format 31/12/2024, 12/31/2024, 2023-12-31, 31.12.2024, 2024/12/31 or 31-12-2024.
locale en, fr, de, es, nl, ru, zh, ja, ar, hi or it.
currency A lowercase ISO currency code, such as usd, eur or gbp.
start_day_of_week First day of the week in calendars: 0 (Sunday) to 6 (Saturday).
events_layout grid_layout or table_layout.
show_message_button_on_profile true or false.
show_message_button_on_events true or false.

Check a booking page URL

curl -X POST "https://timelydo.com/api/v1/users/validate_url_availability" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"user": {"url": "jane-doe-coaching"}}'
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/users/validate_url_availability')

request = Net::HTTP::Post.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request.body = { user: { url: 'jane-doe-coaching' } }.to_json

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  puts body['data']['available'] ? 'Available' : 'Already taken'
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.post(
    "https://timelydo.com/api/v1/users/validate_url_availability",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    json={"user": {"url": "jane-doe-coaching"}},
    timeout=10,
)

body = response.json()

if body["success"]:
    print("Available" if body["data"]["available"] else "Already taken")
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function checkUrl() {
  const response = await fetch("https://timelydo.com/api/v1/users/validate_url_availability", {
    method: "POST",
    headers: {
      "X-API-KEY": process.env.TIMELYDO_API_KEY,
      "Accept": "application/json",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ user: { url: "jane-doe-coaching" } }),
  });

  const body = await response.json();

  if (body.success) {
    console.log(body.data.available ? "Available" : "Already taken");
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

checkUrl();

The response:

{
  "success": true,
  "message": "url is available to use",
  "data": { "available": true },
  "status": 200
}

Tells you whether a booking page URL is free before you update your url. Your own current URL counts as available. URLs are compared in lowercase.

This only checks that nobody else uses the URL. The format rules are applied when you save it.

HTTP Request

POST https://timelydo.com/api/v1/users/validate_url_availability

Body parameters

Parameter Description
user[url] The URL to check.

Upload a profile picture

curl -X PUT "https://timelydo.com/api/v1/users/profile_picture" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json" \
  -F "user[profile_picture]=@avatar.png;type=image/png"
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/users/profile_picture')

request = Net::HTTP::Put.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'

File.open('avatar.png', 'rb') do |image|
  request.set_form(
    [['user[profile_picture]', image, { filename: 'avatar.png', content_type: 'image/png' }]],
    'multipart/form-data'
  )

  response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
    http.request(request)
  end

  body = JSON.parse(response.body)

  if body['success']
    puts body['message']
  else
    warn "#{response.code}: #{body['message']}"
  end
end
import os
import requests

with open("avatar.png", "rb") as image:
    response = requests.put(
        "https://timelydo.com/api/v1/users/profile_picture",
        headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
        files={"user[profile_picture]": ("avatar.png", image, "image/png")},
        timeout=30,
    )

body = response.json()

if body["success"]:
    print(body["message"])
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
const fs = require("fs");

async function uploadProfilePicture() {
  const form = new FormData();
  const image = new Blob([fs.readFileSync("avatar.png")], { type: "image/png" });
  form.append("user[profile_picture]", image, "avatar.png");

  const response = await fetch("https://timelydo.com/api/v1/users/profile_picture", {
    method: "PUT",
    headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json" },
    body: form,
  });

  const body = await response.json();

  if (body.success) {
    console.log(body.message);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

uploadProfilePicture();

A file that is not an allowed image returns 422:

{
  "success": false,
  "message": "unsupported image type. Use PNG, JPEG, GIF, or WEBP.",
  "data": {},
  "status": "unprocessable_entity"
}

Replaces your profile picture. Send the image as multipart/form-data in the user[profile_picture] field. Returns the updated user object. To get the new picture's address, call Get account settings.

HTTP Request

PUT https://timelydo.com/api/v1/users/profile_picture

Rules

Upload errors

HTTP status message Cause
400 missing params No user[profile_picture] field was sent.
422 profile_picture must be an uploaded image file The field was sent as text instead of a file.
422 unsupported image type. Use PNG, JPEG, GIF, or WEBP. The file is not an allowed image type.
422 image must be 5MB or smaller The file is too large.

Remove the profile picture

curl -X DELETE "https://timelydo.com/api/v1/users/remove_profile_picture" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json"
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/users/remove_profile_picture')

request = Net::HTTP::Delete.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  puts body['message']
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.delete(
    "https://timelydo.com/api/v1/users/remove_profile_picture",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    timeout=10,
)

body = response.json()

if body["success"]:
    print(body["message"])
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function removeProfilePicture() {
  const response = await fetch("https://timelydo.com/api/v1/users/remove_profile_picture", {
    method: "DELETE",
    headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json" },
  });

  const body = await response.json();

  if (body.success) {
    console.log(body.message);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

removeProfilePicture();

The response:

{
  "success": true,
  "message": "profile picture removed",
  "data": { "id": "3f6c2a9e-8b1d-4c7a-9e21-5d0b7f4a1c33", "avatar_name": "JD" },
  "status": 200
}

Removes your profile picture. Your initials (avatar_name) are shown instead. Returns the updated user object. Calling it when there is no picture also succeeds.

HTTP Request

DELETE https://timelydo.com/api/v1/users/remove_profile_picture

Events

An event is a bookable meeting type on your booking page, for example a 30 minute intro call. People pick a time from your availability and book it.

The examples below use the event id 7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90. Replace it with one of your own from List your events.

The event object

An event object:

{
  "id": "7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90",
  "name": "Intro call",
  "url": "intro-call",
  "absolute_url": "/jane/intro-call",
  "active": true,
  "is_public": true,
  "hidden_from_profile": false,
  "color": "peacock",
  "slot_duration": "30 Minutes",
  "padding_duration": "0 Minutes",
  "extra_durations": [],
  "location_name": "Google Meet",
  "description": "A quick call to see if we are a good fit.",
  "connected_calendar_email": "jane@example.com",
  "approval_required": false,
  "approval_window_hours": 24,
  "approval_reminder_hours": [6, 1],
  "cancel_cutoff_minutes": null,
  "reschedule_cutoff_minutes": null,
  "refund_window_hours": 24,
  "payment_source": "no_payment",
  "price_amount": null,
  "price_currency": null,
  "deposit_type": "none",
  "deposit_amount": null,
  "deposit_percent": null,
  "balance_due_hours_before": 24,
  "created_at": "17 August, 2026",
  "created_at_ago": "1 month ago",
  "formated_created_at_for_host": "Monday, August 17, 2026 at 09:20 AM",
  "active_event_questions": []
}
Field Type Description
id string Unique id of the event.
name string Name people see when booking.
url string Last part of the booking link.
absolute_url string Path of the booking page, for example /jane/intro-call.
active boolean Whether the event accepts bookings.
is_public boolean true when people can book the event. false makes it private: nobody can book it.
hidden_from_profile boolean true when the event is bookable by its link but not listed on your public profile. Only matters while is_public is true.
color string Colour name used in calendars.
slot_duration string Meeting length, already written for display, such as 30 Minutes.
padding_duration string Gap kept after each meeting, such as 15 Minutes.
extra_durations array of strings Other lengths the person booking may choose instead of slot_duration, for example ["45_minutes", "1_hour"]. Empty means only the main length is offered.
minimum_notice_minutes integer How soon before the start someone can still book, in minutes. 0 means no notice is needed.
booking_window_days integer How many days ahead people can book.
slot_interval_minutes integer or null Minutes between start times in one day. null means a new start time right after each meeting.
max_bookings_per_day integer or null Most bookings accepted in one day, or null for no limit.
max_bookings_per_week integer or null Most bookings accepted in one week (Monday to Sunday), or null for no limit.
booking_limit_scope string event counts only this event's bookings towards the limits, host counts all of yours.
availability_schedule_id string or null Id of the availability calendar the event uses, or null for your default calendar. Manage them on the Availability page.
approval_required boolean true when every booking is a request that waits for your answer. See Answer a booking request.
approval_window_hours integer How many hours you have to answer a request, from 1 to 168. A request nobody answered by then expires.
approval_reminder_hours array of integers How many hours before a request expires you are reminded, for example [6, 1]. Empty means no reminders.
cancel_cutoff_minutes integer or null How late before the start a booker may cancel on their own, in minutes. null means until the meeting starts. You are never held to it.
reschedule_cutoff_minutes integer or null The same for rescheduling.
refund_window_hours integer A paid booking is refunded automatically when the booker cancels more than this many hours before the start. Default 24.
location_name string Where the meeting happens, such as Google Meet or No Location.
description string or null Description shown on the booking page. Line breaks come back as <br>.
connected_calendar_email string or null Calendar the bookings are written to.
payment_source string no_payment for a free event, stripe for one that charges. See Charge for an event.
price_amount integer or null What the event charges, in the smallest unit of price_currency: 4900 is $49.00, and 5000 is 5,000 yen. Only used while payment_source is stripe.
price_currency string or null Lowercase three letter currency code, such as usd or eur.
deposit_type string none (the whole price is paid when booking), fixed or percent. A deposit is paid when booking and the rest is collected later with a payment link by email.
deposit_amount integer or null The deposit for fixed, in the smallest unit of price_currency.
deposit_percent integer or null The deposit for percent, from 1 to 99. Rounded to the nearest unit of the currency.
balance_due_hours_before integer How many hours before the meeting the booker is sent the payment link for the rest. From 1 to 720. Default 24.
created_at string Date the event was created.
active_event_questions array Questions on the booking form. See Booking form questions.

Booking form questions

An entry in active_event_questions:

{
  "id": "6d2b1f4a-9c3e-4a71-8f52-1b7d9e0c4a38",
  "required": true,
  "order_number": 1,
  "created_at_ago": "3 weeks ago",
  "question": {
    "id": "21f8c6b4-7e19-4d3a-95cf-8b204e7a1d66",
    "title": "What would you like to cover?",
    "question_type": "multi_line",
    "state": "active",
    "question_details": {},
    "events_count": 2,
    "created_at": "Monday, August 17, 2026 at 09:22 AM"
  }
}

Each entry links one of your questions to this event. What changes the form while someone fills it in lives in Form rules.

Field Type Description
id string Id of the link between the event and the question. Use it when updating or removing the question from this event.
required boolean Whether the person booking must answer.
order_number integer Position on the form, lowest first.
question.title string The question text.
question.question_type string One of one_line, multi_line, radio_button, check_box, drop_down, phone_number, email, number, boolean, url, date, hidden, rating, file_upload, signature.
question.state string draft, active or archived.
question.question_details object Type specific settings, such as the list of options.
question.events_count integer How many of your events use this question.

List your events

curl "https://timelydo.com/api/v1/events" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json"
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/events')

request = Net::HTTP::Get.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  body['data'].each { |event| puts "#{event['name']} (#{event['slot_duration']})" }
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.get(
    "https://timelydo.com/api/v1/events",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    timeout=10,
)

body = response.json()

if body["success"]:
    for event in body["data"]:
        print(event["name"], event["slot_duration"])
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function listEvents() {
  const response = await fetch("https://timelydo.com/api/v1/events", {
    headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json" },
  });

  const body = await response.json();

  if (body.success) {
    body.data.forEach((event) => console.log(`${event.name} (${event.slot_duration})`));
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

listEvents();

Lists your events, newest first. Deleted events are left out.

HTTP Request

GET https://timelydo.com/api/v1/events

Get an event

curl "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json"
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90')

request = Net::HTTP::Get.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  puts "#{body['data']['name']} has #{body['data']['active_event_questions'].length} questions"
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.get(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    timeout=10,
)

body = response.json()

if body["success"]:
    print(body["data"]["name"], "has", len(body["data"]["active_event_questions"]), "questions")
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function getEvent() {
  const response = await fetch(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90",
    { headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json" } },
  );

  const body = await response.json();

  if (body.success) {
    console.log(`${body.data.name} has ${body.data.active_event_questions.length} questions`);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

getEvent();

Returns one event object. An id that is not yours returns 404, never someone else's event.

HTTP Request

GET https://timelydo.com/api/v1/events/<ID>

Create an event

curl -X POST "https://timelydo.com/api/v1/events" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"event": {"name": "Intro call", "url": "intro-call", "slot_duration": "30_minutes", "color": "peacock", "location": {"name": "no_location"}}}'
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/events')

request = Net::HTTP::Post.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request.body = {
  event: {
    name: 'Intro call',
    url: 'intro-call',
    slot_duration: '30_minutes',
    color: 'peacock',
    location: { name: 'no_location' }
  }
}.to_json

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  puts "Created #{body['data']['name']} at #{body['data']['absolute_url']}"
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.post(
    "https://timelydo.com/api/v1/events",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    json={
        "event": {
            "name": "Intro call",
            "url": "intro-call",
            "slot_duration": "30_minutes",
            "color": "peacock",
            "location": {"name": "no_location"},
        }
    },
    timeout=10,
)

body = response.json()

if body["success"]:
    print("Created", body["data"]["name"], "at", body["data"]["absolute_url"])
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function createEvent() {
  const response = await fetch("https://timelydo.com/api/v1/events", {
    method: "POST",
    headers: {
      "X-API-KEY": process.env.TIMELYDO_API_KEY,
      "Accept": "application/json",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      event: {
        name: "Intro call",
        url: "intro-call",
        slot_duration: "30_minutes",
        color: "peacock",
        location: { name: "no_location" },
      },
    }),
  });

  const body = await response.json();

  if (body.success) {
    console.log(`Created ${body.data.name} at ${body.data.absolute_url}`);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

createEvent();

A value that is not allowed returns 422:

{
  "success": false,
  "message": "Slot duration is not included in the list",
  "data": {},
  "status": "unprocessable_entity"
}

Creates an event and returns it. name and url are required; everything else falls back to a sensible default.

HTTP Request

POST https://timelydo.com/api/v1/events

Body parameters

Parameter Allowed values
name Required. Up to 80 characters.
url Required. Up to 30 characters, unique among your events (links made before this limit keep working). Cannot start with http, https or /, or contain ., ? or #. Check it first.
slot_duration 15_minutes, 30_minutes, 45_minutes, 1_hour or 2_hours.
extra_durations Up to four of 15_minutes, 30_minutes, 45_minutes, 1_hour or 2_hours, each once and never the one in slot_duration. The booker picks one first and sees the times for it. Not available while payment_source is stripe.
padding_duration 0_minutes, 5_minutes, 10_minutes, 15_minutes, 30_minutes, 45_minutes, 1_hour or 2_hours.
minimum_notice_minutes Whole number of minutes, from 0 up to 43200 (30 days). Default 120.
booking_window_days Whole number from 1 to 366. Default 60.
slot_interval_minutes Whole number from 5 to 240, or null for a start time right after each meeting.
max_bookings_per_day Whole number above zero, or null for no limit.
max_bookings_per_week Whole number above zero, or null for no limit.
booking_limit_scope event or host.
availability_schedule_id Id of one of your availability calendars, or null for your default.
approval_required true or false. Not available while payment_source is stripe.
approval_window_hours Whole number from 1 to 168. Default 24.
approval_reminder_hours Up to five different whole numbers of hours, each shorter than approval_window_hours. Others are dropped. Default [6, 1].
cancel_cutoff_minutes Whole number of minutes from 0 up to 43200 (30 days), or null for until the meeting starts.
reschedule_cutoff_minutes The same as cancel_cutoff_minutes, for rescheduling.
refund_window_hours Whole number from 0 to 720. Default 24.
color lavender, sage, grape, flamingo, banana, tangerine, peacock, graphite, blueberry, basil or tomato.
description Up to 500 characters.
location An object with a name of no_location, google_meet, zoom, ask_invitee, in_person or phone_call, plus that type's own settings. See Locations.
active true or false. An inactive event takes no bookings.
is_public true or false. false makes the event private: nobody can book it.
hidden_from_profile true or false. A hidden event is bookable by its link but not listed on your public profile.
enable_guest_emails true or false. Lets the person booking invite guests.
guest_emails_limit How many guests they may add.
connected_calendar_id Id of the calendar bookings are written to, or no_calendar_selected.
payment_source no_payment or stripe. See Charge for an event.
price_amount Whole number in the currency's smallest unit. Required when payment_source is stripe.
price_currency Lowercase currency code. Required when payment_source is stripe. See Charge for an event.
deposit_type none, fixed or percent. Only for an event that charges. A fixed deposit must be less than the price, a percent deposit a whole number from 1 to 99, and both the deposit and the rest must reach the currency's minimum charge.
deposit_amount Whole number in the currency's smallest unit. Required when deposit_type is fixed.
deposit_percent Whole number from 1 to 99. Required when deposit_type is percent.
balance_due_hours_before Whole number from 1 to 720. Default 24.
event_questions_attributes Booking form questions. See Add questions to an event.
form_rules The rules of the booking form, saved together with the event (see The rule object). Sending it, even as an empty list, makes these exactly the rules of the event; leaving it out keeps the existing rules. A rule names its questions by the id of the link on the event, or by q:<question id> for a question added in the same request.

Locations

Location name Extra settings
no_location none
google_meet none. Needs a connected Google calendar.
zoom none. Needs Zoom connected in Integrations.
ask_invitee none. The person booking says where.
in_person details: the address or room.
phone_call selected_option: host_will_call_invitee or invitee_will_call_host, plus host_contact_number or invitee_contact_number.

Update an event

curl -X PATCH "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"event": {"name": "Intro call", "url": "intro-call", "description": "A quick 30 minute chat.", "color": "basil"}}'
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90')

request = Net::HTTP::Patch.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request.body = {
  event: {
    name: 'Intro call',
    url: 'intro-call',
    description: 'A quick 30 minute chat.',
    color: 'basil'
  }
}.to_json

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  puts "Updated #{body['data']['name']}"
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.patch(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    json={
        "event": {
            "name": "Intro call",
            "url": "intro-call",
            "description": "A quick 30 minute chat.",
            "color": "basil",
        }
    },
    timeout=10,
)

body = response.json()

if body["success"]:
    print("Updated", body["data"]["name"])
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function updateEvent() {
  const response = await fetch(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90",
    {
      method: "PATCH",
      headers: {
        "X-API-KEY": process.env.TIMELYDO_API_KEY,
        "Accept": "application/json",
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        event: {
          name: "Intro call",
          url: "intro-call",
          description: "A quick 30 minute chat.",
          color: "basil",
        },
      }),
    },
  );

  const body = await response.json();

  if (body.success) {
    console.log(`Updated ${body.data.name}`);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

updateEvent();

Updates an event and returns it. Send the whole event object, including name and url, the same way the app does.

HTTP Request

PATCH https://timelydo.com/api/v1/events/<ID>

Body parameters

The same as Create an event.

Charge for an event

curl -X PATCH "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"event": {"payment_source": "stripe", "price_amount": 4900, "price_currency": "usd"}}'
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90')

request = Net::HTTP::Patch.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request.body = {
  event: { payment_source: 'stripe', price_amount: 4900, price_currency: 'usd' }
}.to_json

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  data = body['data']
  puts "#{data['name']} now charges #{data['price_amount']} #{data['price_currency']} (smallest unit)"
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.patch(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    json={"event": {"payment_source": "stripe", "price_amount": 4900, "price_currency": "usd"}},
    timeout=10,
)

body = response.json()

if body["success"]:
    data = body["data"]
    print(data["name"], "now charges", data["price_amount"], data["price_currency"], "(smallest unit)")
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function chargeForEvent() {
  const response = await fetch(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90",
    {
      method: "PATCH",
      headers: {
        "X-API-KEY": process.env.TIMELYDO_API_KEY,
        "Accept": "application/json",
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        event: { payment_source: "stripe", price_amount: 4900, price_currency: "usd" },
      }),
    },
  );

  const body = await response.json();

  if (body.success) {
    console.log(`${body.data.name} now charges ${body.data.price_amount} ${body.data.price_currency} (smallest unit)`);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

chargeForEvent();

The event now charges:

{
  "success": true,
  "message": "Event updated successfully",
  "data": {
    "id": "7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90",
    "name": "Intro call",
    "url": "intro-call",
    "payment_source": "stripe",
    "price_amount": 4900,
    "price_currency": "usd"
  }
}

Stripe is not ready yet, 422:

{
  "success": false,
  "message": "Connect Stripe and finish its setup before you charge for an event.",
  "data": {}
}

Makes an event charge, or stops it charging. The booking page then asks the person booking to pay by card with Stripe before the meeting is booked, and the payment goes straight to your own Stripe account.

Connecting Stripe cannot be done through the API: do it once in the app, under Integrations, then Stripe. Until your Stripe account can take payments, switching an event to stripe is refused with 422. Switch it back with "payment_source": "no_payment".

Things to know:

HTTP Request

PATCH https://timelydo.com/api/v1/events/<ID>

Body parameters

Parameter Allowed values
payment_source stripe to charge, no_payment to be free.
price_amount Whole number in the currency's smallest unit. Required with stripe.
price_currency Lowercase currency code, such as usd, eur, gbp or jpy. Required with stripe.

Add questions to an event

curl -X PATCH "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"event": {"name": "Intro call", "url": "intro-call", "event_questions_attributes": []}}'
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90')

request = Net::HTTP::Patch.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'

# One entry per question the form should have, in the order people see them.
request.body = {
  event: {
    name: 'Intro call',
    url: 'intro-call',
    event_questions_attributes: []
  }
}.to_json

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  puts "Form now has #{body['data']['active_event_questions'].length} questions"
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

# One entry per question the form should have, in the order people see them.
response = requests.patch(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    json={
        "event": {
            "name": "Intro call",
            "url": "intro-call",
            "event_questions_attributes": [],
        }
    },
    timeout=10,
)

body = response.json()

if body["success"]:
    print("Form now has", len(body["data"]["active_event_questions"]), "questions")
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function setEventQuestions() {
  const response = await fetch(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90",
    {
      method: "PATCH",
      headers: {
        "X-API-KEY": process.env.TIMELYDO_API_KEY,
        "Accept": "application/json",
        "Content-Type": "application/json",
      },
      // One entry per question the form should have, in the order people see them.
      body: JSON.stringify({
        event: {
          name: "Intro call",
          url: "intro-call",
          event_questions_attributes: [],
        },
      }),
    },
  );

  const body = await response.json();

  if (body.success) {
    console.log(`Form now has ${body.data.active_event_questions.length} questions`);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

setEventQuestions();

One entry per question, for example:

{
  "event_questions_attributes": [
    { "question_id": "21f8c6b4-7e19-4d3a-95cf-8b204e7a1d66", "required": true, "order_number": 1 },
    { "id": "6d2b1f4a-9c3e-4a71-8f52-1b7d9e0c4a38", "required": false, "order_number": 2 },
    { "id": "5c1a0e39-3b77-4d02-9a6e-7f1c8d25b430", "_destroy": true }
  ]
}

Questions belong to your account and are reused across events. This endpoint decides which of them appear on one event's booking form, in what order, and which are required.

Key Use it to
question_id Add one of your questions to this event.
id Change a question already on this event. It is the entry's own id from active_event_questions, not the question id.
required Whether an answer is needed.
order_number Position on the form, lowest first.
_destroy Set to true with the entry's id to take the question off this event. The question itself is kept.

A question_id that is not yours is ignored rather than rejected, so the rest of the update still applies.

HTTP Request

PATCH https://timelydo.com/api/v1/events/<ID>

The rule object

A rule:

{
  "id": "c4e8a6b2-3d1f-4a9e-8b57-2f6d0c9a1b34",
  "position": 1,
  "title": null,
  "match": "all",
  "paused": false,
  "conditions": [
    { "question_id": "6d2b1f4a-9c3e-4a71-8f52-1b7d9e0c4a38", "operator": "equals", "value": "yes" }
  ],
  "actions": [
    { "type": "show", "question_ids": ["9a4c7e21-5b83-4d06-b1e9-7c3f2a8d5e10"] }
  ],
  "created_at": "2026-10-31T09:03:46.614Z",
  "warnings": []
}

A rule belongs to one event. It reads answers (conditions) and does something (actions). Both refer to questions by the id of the link between the event and the question, the id in Booking form questions.

Field Type Description
id string Id of the rule.
position integer The order rules are read in.
match string all or any.
paused boolean A paused rule is kept but does not run.
conditions array question_id, operator and value. The operators depend on the question type: equals, not_equals, contains, not_contains, greater_than, less_than, greater_than_or_equal, less_than_or_equal, before, after, is_answered and is_blank.
actions array See below.
warnings array Plain sentences about a rule that no longer makes sense, for example after a question was moved.

An action has a type:

Type Other fields What it does
show question_ids The questions stay hidden until the rule applies.
hide question_ids The questions disappear when the rule applies.
require question_ids The questions must be answered.
stop message, soft Stops the booking with message. With soft: true the message is a warning and the booking is allowed.
send event_id Sends the visitor to another of your events.

A rule can only read questions that come before the questions it changes. When one rule hides a question and another shows it, hiding wins. The server applies the same rules as the booking page, so a booking sent directly is held to them too.

List the rules of an event

curl -X GET "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/rules" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json"
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/rules')

request = Net::HTTP::Get.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  body['data']['rules'].each { |rule| puts "#{rule['position']}: #{rule['match']} of #{rule['conditions'].length} conditions" }
else
  puts "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.get(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/rules",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    timeout=10,
)

body = response.json()
if body["success"]:
    for rule in body["data"]["rules"]:
        print(rule["position"], rule["match"], len(rule["conditions"]), "conditions")
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function listRules() {
  const response = await fetch(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/rules",
    {
      method: "GET",
      headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json" },
    },
  );

  const body = await response.json();

  if (body.success) {
    body.data.rules.forEach((rule) => console.log(`${rule.position}: ${rule.match} of ${rule.conditions.length} conditions`));
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

listRules();

Returns the rules of an event's booking form, in the order they are read, the most rules an event can have (max, currently 30) and your other events (other_events), which a rule can send people to.

HTTP Request

GET https://timelydo.com/api/v1/events/<ID>/rules

Response

Field Type Description
rules array The rules, see The rule object.
max integer The most rules one event can have.
other_events array id and name of your other events.

Create a rule

curl -X POST "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/rules" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"rule":{"match":"all","conditions":[{"question_id":"6d2b1f4a-9c3e-4a71-8f52-1b7d9e0c4a38","operator":"equals","value":"yes"}],"actions":[{"type":"show","question_ids":["9a4c7e21-5b83-4d06-b1e9-7c3f2a8d5e10"]}]}}'
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/rules')

request = Net::HTTP::Post.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request.body = JSON.generate({
  "rule": {
    "match": "all",
    "conditions": [
      {
        "question_id": "6d2b1f4a-9c3e-4a71-8f52-1b7d9e0c4a38",
        "operator": "equals",
        "value": "yes"
      }
    ],
    "actions": [
      {
        "type": "show",
        "question_ids": [
          "9a4c7e21-5b83-4d06-b1e9-7c3f2a8d5e10"
        ]
      }
    ]
  }
})

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

puts body['success'] ? "Rule #{body['data']['id']} saved" : "#{response.code}: #{body['message']}"
import os
import requests

response = requests.post(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/rules",
    json={
        "rule": {
            "match": "all",
            "conditions": [
                {
                    "question_id": "6d2b1f4a-9c3e-4a71-8f52-1b7d9e0c4a38",
                    "operator": "equals",
                    "value": "yes"
                }
            ],
            "actions": [
                {
                    "type": "show",
                    "question_ids": [
                        "9a4c7e21-5b83-4d06-b1e9-7c3f2a8d5e10"
                    ]
                }
            ]
        }
    },
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    timeout=10,
)

body = response.json()
if body["success"]:
    print("Rule saved:", body["data"]["id"])
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function createRule() {
  const response = await fetch(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/rules",
    {
      method: "POST",
      headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json", "Content-Type": "application/json" },
      body: JSON.stringify({
        "rule": {
          "match": "all",
          "conditions": [
            {
              "question_id": "6d2b1f4a-9c3e-4a71-8f52-1b7d9e0c4a38",
              "operator": "equals",
              "value": "yes"
            }
          ],
          "actions": [
            {
              "type": "show",
              "question_ids": [
                "9a4c7e21-5b83-4d06-b1e9-7c3f2a8d5e10"
              ]
            }
          ]
        }
      }),
    },
  );

  const body = await response.json();

  if (body.success) {
    console.log(`Rule saved: ${body.data.id}`);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

createRule();

Adds a rule to the event. The rule above reads: when the first question is answered yes, show the second one. It returns the saved rule, or 422 with a message that says what is wrong, for example a rule that reads a question that comes after the ones it changes.

HTTP Request

POST https://timelydo.com/api/v1/events/<ID>/rules

Body parameters

Parameter Description
rule.match all or any: whether every condition or one of them must be true. Defaults to all.
rule.paused true to keep the rule but not run it.
rule.conditions What the rule reads: a list of question_id (the id of a booking form question link), operator and value.
rule.actions What the rule does, see The rule object.

Update a rule

curl -X PUT "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/rules/c4e8a6b2-3d1f-4a9e-8b57-2f6d0c9a1b34" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"rule":{"match":"all","conditions":[{"question_id":"6d2b1f4a-9c3e-4a71-8f52-1b7d9e0c4a38","operator":"equals","value":"yes"}],"actions":[{"type":"show","question_ids":["9a4c7e21-5b83-4d06-b1e9-7c3f2a8d5e10"]}],"paused":true}}'
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/rules/c4e8a6b2-3d1f-4a9e-8b57-2f6d0c9a1b34')

request = Net::HTTP::Put.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request.body = JSON.generate({
  "rule": {
    "match": "all",
    "conditions": [
      {
        "question_id": "6d2b1f4a-9c3e-4a71-8f52-1b7d9e0c4a38",
        "operator": "equals",
        "value": "yes"
      }
    ],
    "actions": [
      {
        "type": "show",
        "question_ids": [
          "9a4c7e21-5b83-4d06-b1e9-7c3f2a8d5e10"
        ]
      }
    ],
    "paused": true
  }
})

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

puts body['success'] ? "Rule paused: #{body['data']['paused']}" : "#{response.code}: #{body['message']}"
import os
import requests

response = requests.put(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/rules/c4e8a6b2-3d1f-4a9e-8b57-2f6d0c9a1b34",
    json={
        "rule": {
            "match": "all",
            "conditions": [
                {
                    "question_id": "6d2b1f4a-9c3e-4a71-8f52-1b7d9e0c4a38",
                    "operator": "equals",
                    "value": "yes"
                }
            ],
            "actions": [
                {
                    "type": "show",
                    "question_ids": [
                        "9a4c7e21-5b83-4d06-b1e9-7c3f2a8d5e10"
                    ]
                }
            ],
            "paused": True
        }
    },
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    timeout=10,
)

body = response.json()
if body["success"]:
    print("Paused:", body["data"]["paused"])
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function updateRule() {
  const response = await fetch(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/rules/c4e8a6b2-3d1f-4a9e-8b57-2f6d0c9a1b34",
    {
      method: "PUT",
      headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json", "Content-Type": "application/json" },
      body: JSON.stringify({
        "rule": {
          "match": "all",
          "conditions": [
            {
              "question_id": "6d2b1f4a-9c3e-4a71-8f52-1b7d9e0c4a38",
              "operator": "equals",
              "value": "yes"
            }
          ],
          "actions": [
            {
              "type": "show",
              "question_ids": [
                "9a4c7e21-5b83-4d06-b1e9-7c3f2a8d5e10"
              ]
            }
          ],
          "paused": true
        }
      }),
    },
  );

  const body = await response.json();

  if (body.success) {
    console.log(`Paused: ${body.data.paused}`);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

updateRule();

Changes a rule and returns it. Send the whole rule, with its conditions and actions, the same way the app does: leaving them out is refused.

HTTP Request

PUT https://timelydo.com/api/v1/events/<ID>/rules/<RULE_ID>

Delete a rule

curl -X DELETE "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/rules/c4e8a6b2-3d1f-4a9e-8b57-2f6d0c9a1b34" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json"
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/rules/c4e8a6b2-3d1f-4a9e-8b57-2f6d0c9a1b34')

request = Net::HTTP::Delete.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

puts body['success'] ? body['message'] : "#{response.code}: #{body['message']}"
import os
import requests

response = requests.delete(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/rules/c4e8a6b2-3d1f-4a9e-8b57-2f6d0c9a1b34",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    timeout=10,
)

body = response.json()
print(body["message"] if body["success"] else f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function deleteRule() {
  const response = await fetch(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/rules/c4e8a6b2-3d1f-4a9e-8b57-2f6d0c9a1b34",
    {
      method: "DELETE",
      headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json" },
    },
  );

  const body = await response.json();

  if (body.success) {
    console.log(body.message);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

deleteRule();

Deletes a rule. The questions stay as they are.

HTTP Request

DELETE https://timelydo.com/api/v1/events/<ID>/rules/<RULE_ID>

Copy questions from another event

curl -X POST "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/copy_questions" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"from_event_id":"2f9d8e1c-6a4b-4c70-93e5-0b1a7d3c5f82","event_question_ids":["6d2b1f4a-9c3e-4a71-8f52-1b7d9e0c4a38"]}'
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/copy_questions')

request = Net::HTTP::Post.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request.body = JSON.generate({
  "from_event_id": "2f9d8e1c-6a4b-4c70-93e5-0b1a7d3c5f82",
  "event_question_ids": [
    "6d2b1f4a-9c3e-4a71-8f52-1b7d9e0c4a38"
  ]
})

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

puts body['success'] ? body['message'] : "#{response.code}: #{body['message']}"
import os
import requests

response = requests.post(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/copy_questions",
    json={
        "from_event_id": "2f9d8e1c-6a4b-4c70-93e5-0b1a7d3c5f82",
        "event_question_ids": [
            "6d2b1f4a-9c3e-4a71-8f52-1b7d9e0c4a38"
        ]
    },
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    timeout=10,
)

body = response.json()
print(body["message"] if body["success"] else f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function copyQuestions() {
  const response = await fetch(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/copy_questions",
    {
      method: "POST",
      headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json", "Content-Type": "application/json" },
      body: JSON.stringify({
        "from_event_id": "2f9d8e1c-6a4b-4c70-93e5-0b1a7d3c5f82",
        "event_question_ids": [
          "6d2b1f4a-9c3e-4a71-8f52-1b7d9e0c4a38"
        ]
      }),
    },
  );

  const body = await response.json();

  if (body.success) {
    console.log(body.message);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

copyQuestions();

Adds questions of another of your events to this one, after the questions it already has. Leave event_question_ids out to take every question. A question the event already has is not added twice. Rules between the copied questions are copied too, pointing at the new questions; a rule that needs a question you did not copy is left out and counted in skipped_rules.

HTTP Request

POST https://timelydo.com/api/v1/events/<ID>/copy_questions

Response

Field Type Description
event_questions array The questions that were added: id, question_id, required and order_number.
rules integer How many rules were copied.
skipped_rules integer How many rules were left out.

Duplicate an event

curl -X POST "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/clone" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json"
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/clone')

request = Net::HTTP::Post.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  puts "Copy created: #{body['data']['name']}"
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.post(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/clone",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    timeout=10,
)

body = response.json()

if body["success"]:
    print("Copy created:", body["data"]["name"])
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function cloneEvent() {
  const response = await fetch(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/clone",
    { method: "POST", headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json" } },
  );

  const body = await response.json();

  if (body.success) {
    console.log(`Copy created: ${body.data.name}`);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

cloneEvent();

Copies an event, including its booking form questions and its form rules. The copy is named [CLONE] <name> and gets its own URL. Rename it with Update an event.

Names are limited to 80 characters, so cloning an event whose name is already near the limit returns 422.

HTTP Request

POST https://timelydo.com/api/v1/events/<ID>/clone

Delete an event

curl -X DELETE "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json"
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90')

request = Net::HTTP::Delete.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

puts body['success'] ? body['message'] : "#{response.code}: #{body['message']}"
import os
import requests

response = requests.delete(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    timeout=10,
)

body = response.json()
print(body["message"] if body["success"] else f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function deleteEvent() {
  const response = await fetch(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90",
    { method: "DELETE", headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json" } },
  );

  const body = await response.json();

  if (body.success) {
    console.log(body.message);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

deleteEvent();

Removes the event from your booking page. Existing bookings are kept, and the event stops appearing in List your events. Its URL becomes free for a new event.

HTTP Request

DELETE https://timelydo.com/api/v1/events/<ID>

List bookings for an event

curl "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/bookings?category=upcoming" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json"
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/bookings')
uri.query = URI.encode_www_form(category: 'upcoming')

request = Net::HTTP::Get.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  puts "#{body['data']['page']['total_records']} bookings"
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.get(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/bookings",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    params={"category": "upcoming"},
    timeout=10,
)

body = response.json()

if body["success"]:
    print(body["data"]["page"]["total_records"], "bookings")
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function listEventBookings() {
  const response = await fetch(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/bookings?category=upcoming",
    { headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json" } },
  );

  const body = await response.json();

  if (body.success) {
    console.log(`${body.data.page.total_records} bookings`);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

listEventBookings();

The response (trimmed):

{
  "success": true,
  "message": "Bookings found",
  "data": {
    "page": { "limit": 10, "number": 1, "total_pages": 1, "total_records": 2, "current_page": 1 },
    "records": [
      {
        "id": "b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915",
        "status": "confirmed",
        "event_name": "Intro call",
        "color": "peacock",
        "attendee_name": "Sam Rivera",
        "attendee_email": "sam@example.com",
        "host_ui_booking_range_with_date": "Wednesday, 23 September 2026, 10:00am to 10:30am",
        "ui_duration": "30 Minutes",
        "formatted_location_name": "Google Meet",
        "internal_notes": null,
        "can_be_rescheduled": true,
        "private_key": "8c2f5b19-77ad-4e30-9c6b-1f0a2d3e4b58"
      }
    ]
  },
  "status": 200
}

Lists the bookings made for one event, grouped into the same categories the app shows.

Each record is a booking. These are the fields you will use most:

Field Type Description
id string Unique id of the booking.
status string confirmed, pending (a request waiting for your answer), declined, expired, canceled, rescheduled, external_payment_in_pending or payment_rejected.
attendee_name, attendee_email string The person who booked.
outcome string or null completed or no_show once you marked a confirmed meeting, otherwise null.
approval_expires_at string or null When an unanswered request expires.
decline_reason string or null The message you gave when declining.
answers array The answers to your booking form, each with title and answer.
internal_notes string or null Your private notes on the booking.
utm_source, utm_medium, utm_campaign, utm_content, utm_term string or null Where the booker came from, taken from the link they arrived with.
booking_url string The booker's own page for this booking, where they can reschedule or cancel. Send it to them if they lost their email.
can_be_rescheduled boolean Whether the booking can still be moved.
private_key string The reference printed in the booking emails.

HTTP Request

GET https://timelydo.com/api/v1/events/<ID>/bookings

Query parameters

Parameter Default Description
category upcoming upcoming, recent, past, canceled, archived, requests or closed. Anything else falls back to upcoming.
q none Only bookings whose booker's name or email contains this text.
date_from none Earliest day to include, as YYYY-MM-DD. Leave out both dates to get every date. You may give only one of the two.
date_to none Latest day to include, as YYYY-MM-DD.
page[number] 1 Page to return.
page[limit] 10 Bookings per page, up to 500.

Dates that cannot be read are ignored rather than failing.

Category What it holds
upcoming Confirmed bookings from now on, soonest first.
recent The same bookings, newest booked first.
past Confirmed bookings that already happened.
canceled Canceled bookings still in the future.
archived Bookings you archived, whatever their status.
requests Booking requests still waiting for your answer, the one that expires first at the top.
closed Requests that were declined or ran out.

Count bookings for an event

curl "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/bookings_counts" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json"
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/bookings_counts')

request = Net::HTTP::Get.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  puts "#{body['data']['upcoming']} upcoming, #{body['data']['past']} past"
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.get(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/bookings_counts",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    timeout=10,
)

body = response.json()

if body["success"]:
    print(body["data"]["upcoming"], "upcoming,", body["data"]["past"], "past")
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function countEventBookings() {
  const response = await fetch(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/bookings_counts",
    { headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json" } },
  );

  const body = await response.json();

  if (body.success) {
    console.log(`${body.data.upcoming} upcoming, ${body.data.past} past`);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

countEventBookings();

The response:

{
  "success": true,
  "message": "Counts fetched",
  "data": { "recent": 2, "upcoming": 2, "past": 5, "canceled": 1, "archived": 0, "requests": 1, "closed": 3 },
  "status": 200
}

Returns how many bookings sit in each category, for building tabs without fetching every list. Takes the same date_from, date_to and q parameters as the bookings list.

HTTP Request

GET https://timelydo.com/api/v1/events/<ID>/bookings_counts

List attendees for an event

curl "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/attendees" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json"
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/attendees')

request = Net::HTTP::Get.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  body['data'].each { |person| puts "#{person['email']}: #{person['bookings_count']} bookings" }
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.get(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/attendees",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    timeout=10,
)

body = response.json()

if body["success"]:
    for person in body["data"]:
        print(f"{person['email']}: {person['bookings_count']} bookings")
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function listEventAttendees() {
  const response = await fetch(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/attendees",
    { headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json" } },
  );

  const body = await response.json();

  if (body.success) {
    body.data.forEach((person) => console.log(`${person.email}: ${person.bookings_count} bookings`));
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

listEventAttendees();

The response (trimmed):

{
  "success": true,
  "message": "Attendees found",
  "data": [
    {
      "email": "sam@example.com",
      "full_name": "Sam Rivera",
      "avatar_name": "SR",
      "bookings_count": 3,
      "canceled_count": 1,
      "first_appeared": "Monday, 17 August 2026",
      "last_appeared": "Wednesday, 23 September 2026",
      "first_appeared_at": "2026-08-17T09:20:11Z",
      "last_appeared_at": "2026-09-23T08:02:47Z",
      "is_timelydo_user": false,
      "logo": null,
      "ids": ["c71e0a24-9d3b-4f85-a6e2-0b8c15d97f43"]
    }
  ],
  "status": 200
}

Lists everyone who has booked this event, one entry per email address, with how many times they booked and when they first and last did. Rescheduled bookings are left out, and canceled ones are counted separately in canceled_count.

is_timelydo_user says whether that person also has a TimelyDo account.

HTTP Request

GET https://timelydo.com/api/v1/events/<ID>/attendees

Answer a booking request

curl -X PUT "https://timelydo.com/api/v1/bookings/b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915/decline" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"reason": "Sorry, that week is full."}'
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/bookings/b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915/decline')

request = Net::HTTP::Put.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request.body = { reason: 'Sorry, that week is full.' }.to_json

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  puts body['message']
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.put(
    "https://timelydo.com/api/v1/bookings/b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915/decline",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    json={"reason": "Sorry, that week is full."},
    timeout=10,
)

body = response.json()

if body["success"]:
    print(body["message"])
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function declineRequest() {
  const response = await fetch(
    "https://timelydo.com/api/v1/bookings/b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915/decline",
    { method: "PUT", headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json", "Content-Type": "application/json" }, body: JSON.stringify({"reason": "Sorry, that week is full."}) },
  );

  const body = await response.json();

  if (body.success) {
    console.log(body.message);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

declineRequest();

The response (trimmed):

{
  "success": true,
  "message": "Request declined. The time is free again.",
  "data": {
    "id": "b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915",
    "status": "declined",
    "decline_reason": "Sorry, that week is full.",
    "attendee_email": "sam@example.com"
  },
  "status": 200
}

When an event asks for your approval, a booking is a request that holds the time until you answer. Approving confirms the booking and the booker gets the confirmation email. Declining frees the time and the booker gets your message.

A request can only be answered once. Answering one that was already answered, or whose time ran out, returns 422 with the reason in message.

HTTP Request

PUT https://timelydo.com/api/v1/bookings/<ID>/approve

PUT https://timelydo.com/api/v1/bookings/<ID>/decline

Body parameters

Parameter Description
reason Decline only. A message for the booker, up to 500 characters. Optional.

Mark a meeting completed or no show

curl -X PUT "https://timelydo.com/api/v1/bookings/b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915/outcome" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"outcome": "no_show"}'
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/bookings/b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915/outcome')

request = Net::HTTP::Put.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request.body = { outcome: 'no_show' }.to_json

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  puts body['message']
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.put(
    "https://timelydo.com/api/v1/bookings/b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915/outcome",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    json={"outcome": "no_show"},
    timeout=10,
)

body = response.json()

if body["success"]:
    print(body["message"])
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function markOutcome() {
  const response = await fetch(
    "https://timelydo.com/api/v1/bookings/b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915/outcome",
    { method: "PUT", headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json", "Content-Type": "application/json" }, body: JSON.stringify({"outcome": "no_show"}) },
  );

  const body = await response.json();

  if (body.success) {
    console.log(body.message);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

markOutcome();

The response (trimmed):

{
  "success": true,
  "message": "Saved.",
  "data": { "id": "b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915", "outcome": "no_show", "outcome_marked_at": "2026-09-23T10:41:07.512Z" },
  "status": 200
}

Records what happened at a confirmed meeting. A no_show can be set once the meeting has started, completed once it has ended. Send an empty outcome to take the mark back. Anything else returns 422.

HTTP Request

PUT https://timelydo.com/api/v1/bookings/<ID>/outcome

Body parameters

Parameter Allowed values
outcome completed, no_show, or an empty string to clear it.

Send a confirmation again

curl -X POST "https://timelydo.com/api/v1/bookings/b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915/resend_confirmation" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json"
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/bookings/b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915/resend_confirmation')

request = Net::HTTP::Post.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  puts body['message']
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.post(
    "https://timelydo.com/api/v1/bookings/b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915/resend_confirmation",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    timeout=10,
)

body = response.json()

if body["success"]:
    print(body["message"])
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function resendConfirmation() {
  const response = await fetch(
    "https://timelydo.com/api/v1/bookings/b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915/resend_confirmation",
    { method: "POST", headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json" } },
  );

  const body = await response.json();

  if (body.success) {
    console.log(body.message);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

resendConfirmation();

The response (trimmed):

{
  "success": true,
  "message": "The confirmation was sent again.",
  "data": { "id": "b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915", "status": "confirmed" },
  "status": 200
}

Emails the booker their confirmation again. It only works for a confirmed booking that has not happened yet, and only the booker gets it. Any other booking returns 422.

HTTP Request

POST https://timelydo.com/api/v1/bookings/<ID>/resend_confirmation

Export bookings

curl "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/bookings_export?category=all" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: text/csv" \
  -o bookings.csv
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/bookings_export')
uri.query = URI.encode_www_form(category: 'all')

request = Net::HTTP::Get.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'text/csv'

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

if response.is_a?(Net::HTTPSuccess)
  File.write('bookings.csv', response.body)
  puts 'Saved bookings.csv'
else
  warn "#{response.code}: #{JSON.parse(response.body)['message']}"
end
import os
import requests

response = requests.get(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/bookings_export",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "text/csv"},
    params={"category": "all"},
    timeout=10,
)

if response.ok:
    with open("bookings.csv", "wb") as file:
        file.write(response.content)
    print("Saved bookings.csv")
else:
    print(f"{response.status_code}: {response.json()['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function exportBookings() {
  const response = await fetch(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/bookings_export?category=all",
    { headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "text/csv" } },
  );

  if (response.ok) {
    require("node:fs").writeFileSync("bookings.csv", Buffer.from(await response.arrayBuffer()));
    console.log("Saved bookings.csv");
  } else {
    console.error(`${response.status}: ${(await response.json()).message}`);
  }
}

exportBookings();

The first lines of the file:

Name,Email,Status,Outcome,Event,Date,Start,End,Booker time zone,Answers,Notes,Reason,Booked on,Reference,Source,Medium,Campaign
Sam Rivera,sam@example.com,confirmed,,Intro call,2026-09-23,10:00,10:30,Europe/Berlin,What would you like to discuss?: Our onboarding plan.,,,2026-09-18 14:02,8c2f5b19-77ad-4e30-9c6b-1f0a2d3e4b58,qr_code,offline,poster

Downloads the bookings of an event as a CSV file for a spreadsheet. It takes the same category, q, date_from and date_to parameters as the bookings list, and category=all gives every status. Times are written in your time zone. A cell that a spreadsheet would read as a formula starts with a single quote, so a booker cannot hide a formula in their name.

HTTP Request

GET https://timelydo.com/api/v1/events/<ID>/bookings_export

curl -X POST "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/booking_links" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"prefill_name": "Sam Rivera", "prefill_email": "sam@example.com", "expires_in_days": 7}'
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/booking_links')

request = Net::HTTP::Post.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request.body = { prefill_name: 'Sam Rivera', prefill_email: 'sam@example.com', expires_in_days: 7 }.to_json

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  puts body['data']['url']
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.post(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/booking_links",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    json={"prefill_name": "Sam Rivera", "prefill_email": "sam@example.com", "expires_in_days": 7},
    timeout=10,
)

body = response.json()

if body["success"]:
    print(body["data"]["url"])
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function createLink() {
  const response = await fetch(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/booking_links",
    { method: "POST", headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json", "Content-Type": "application/json" }, body: JSON.stringify({"prefill_name": "Sam Rivera", "prefill_email": "sam@example.com", "expires_in_days": 7}) },
  );

  const body = await response.json();

  if (body.success) {
    console.log(body.data.url);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

createLink();

The response:

{
  "success": true,
  "message": "The link is ready. It works once.",
  "data": {
    "id": "995d63fb-0c9b-441b-9356-a720b0da56bd",
    "prefill_name": "Sam Rivera",
    "prefill_email": "sam@example.com",
    "state": "open",
    "progress": "not_opened",
    "expires_at": "2026-09-25T09:00:00.000Z",
    "used_at": null,
    "booking_id": null,
    "booking": null,
    "opened_count": 0,
    "first_opened_at": null,
    "last_opened_at": null,
    "created_at": "2026-09-18T09:00:00.000Z",
    "url": "https://timelydo.com/jane/intro-call?link_token=9bQ2xK7mV3tLw0pRzA5nYcE8hJdFgU1s"
  },
  "status": 200
}

Makes a link for one person. It works for one booking only, and it can have the person's name and email filled in and an expiry. The name and email stay on our side and are filled in on the booking page, so the link itself only carries a random code. Single use links work for free events.

Whoever opens a link that was used, has run out or was switched off sees a message and cannot book.

HTTP Request

POST https://timelydo.com/api/v1/events/<ID>/booking_links

GET https://timelydo.com/api/v1/events/<ID>/booking_links lists the links you made, newest first (up to 500), with what happened to each:

Field Description
state open, used, expired or revoked.
progress Where the person is: not_opened, opened (looked, has not booked), booked, expired or revoked.
opened_count How many times the person opened the link. Several page loads within half an hour count as one visit. Your own visits and robots are not counted.
first_opened_at, last_opened_at When it was first and last opened, or null.
used_at When it was used to book, or null.
booking The booking it made (id, status, start_at, end_at, attendee_name, attendee_email), or null.

DELETE https://timelydo.com/api/v1/events/<ID>/booking_links/<LINK_ID> switches an open link off. A link is never deleted: the booking it made keeps pointing at it.

Body parameters

Parameter Description
prefill_name Name to fill in for the person. Up to 120 characters. Optional.
prefill_email Email to fill in. Must be a valid address. Optional.
expires_in_days Whole number from 1 to 365. Leave it out for a link that never runs out.

Notes and tags on an attendee

curl -X PUT "https://timelydo.com/api/v1/attendee_profile" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"email": "sam@example.com", "notes": "Prefers mornings.", "tags": ["vip", "coaching"]}'
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/attendee_profile')

request = Net::HTTP::Put.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request.body = { email: 'sam@example.com', notes: 'Prefers mornings.', tags: ['vip', 'coaching'] }.to_json

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  puts body['message']
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.put(
    "https://timelydo.com/api/v1/attendee_profile",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    json={"email": "sam@example.com", "notes": "Prefers mornings.", "tags": ["vip", "coaching"]},
    timeout=10,
)

body = response.json()

if body["success"]:
    print(body["message"])
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function saveAttendeeNotes() {
  const response = await fetch(
    "https://timelydo.com/api/v1/attendee_profile",
    { method: "PUT", headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json", "Content-Type": "application/json" }, body: JSON.stringify({"email": "sam@example.com", "notes": "Prefers mornings.", "tags": ["vip", "coaching"]}) },
  );

  const body = await response.json();

  if (body.success) {
    console.log(body.message);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

saveAttendeeNotes();

The response:

{
  "success": true,
  "message": "Saved.",
  "data": { "notes": "Prefers mornings.", "tags": ["vip", "coaching"] },
  "status": 200
}

Saves your private notes and tags about one person who booked you. Only you can see them, and they stay with the person across all your events. Send only the fields you want to change: leave out tags to keep the tags. The person must have a booking with you, otherwise you get 404.

HTTP Request

PUT https://timelydo.com/api/v1/attendee_profile

Body parameters

Parameter Description
email Required. The email address the person booked with.
notes Up to 2000 characters.
tags Up to 10 tags of up to 30 characters each. They are saved in lower case.

Check an event URL

curl -X POST "https://timelydo.com/api/v1/events/validate_url_availability" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"event": {"url": "discovery-call"}}'
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/events/validate_url_availability')

request = Net::HTTP::Post.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request.body = { event: { url: 'discovery-call' } }.to_json

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

puts body['data']['available'] ? 'Available' : 'Already taken'
import os
import requests

response = requests.post(
    "https://timelydo.com/api/v1/events/validate_url_availability",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    json={"event": {"url": "discovery-call"}},
    timeout=10,
)

body = response.json()
print("Available" if body["data"]["available"] else "Already taken")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function checkEventUrl() {
  const response = await fetch("https://timelydo.com/api/v1/events/validate_url_availability", {
    method: "POST",
    headers: {
      "X-API-KEY": process.env.TIMELYDO_API_KEY,
      "Accept": "application/json",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ event: { url: "discovery-call" } }),
  });

  const body = await response.json();
  console.log(body.data.available ? "Available" : "Already taken");
}

checkEventUrl();

The response:

{
  "success": true,
  "message": "url is available to use",
  "data": { "available": true },
  "status": 200
}

Tells you whether an event URL is free before you create or rename an event. URLs only need to be unique among your own events, so someone else using the same word does not block you.

Send event[id] as well when renaming an existing event, so its current URL counts as available.

HTTP Request

POST https://timelydo.com/api/v1/events/validate_url_availability

Body parameters

Parameter Description
event[url] The URL to check.
event[id] Optional. The event being renamed.

Bookings

Find free times, book a client, move a booking and cancel it, with your API key only. These calls do what the host does in the app and follow the same rules: the free times of the event, the busy times of the host, the cancel and refund policy of the event. Every booking is the booking object you already know from the bookings list.

The whole flow in four calls: find free times, book a client, move the booking or cancel it. After the meeting, mark it completed or no show. Requests that wait for your answer are handled with approve and decline.

Get a booking

curl "https://timelydo.com/api/v1/bookings/b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json"
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/bookings/b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915')

request = Net::HTTP::Get.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  puts "#{body['data']['status']} #{body['data']['attendee_email']}"
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.get(
    "https://timelydo.com/api/v1/bookings/b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    timeout=10,
)

body = response.json()

if body["success"]:
    print(body["data"]["status"], body["data"]["attendee_email"])
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function getBooking() {
  const response = await fetch("https://timelydo.com/api/v1/bookings/b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915", {
    headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json" },
  });

  const body = await response.json();

  if (body.success) {
    console.log(`${body.data.status} ${body.data.attendee_email}`);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

getBooking();

The response (trimmed):

{
  "success": true,
  "message": "Booking found",
  "data": {
    "id": "b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915",
    "status": "confirmed",
    "event_name": "Intro call",
    "color": "peacock",
    "attendee_name": "Sam Rivera",
    "attendee_email": "sam@example.com",
    "attendee_timezone_titleize": "Europe/Berlin",
    "host_ui_booking_range_with_date": "Wednesday, 23 September 2026, 10:00am to 10:30am",
    "ui_duration": "30 Minutes",
    "booking_url": "https://timelydo.com/jane/intro-call/bookings/b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915",
    "can_be_rescheduled": true,
    "outcome": null,
    "canceled_reason": null,
    "answers": [],
    "private_key": "8c2f5b19-77ad-4e30-9c6b-1f0a2d3e4b58",
    "refundable_payments": [],
    "refund_log": []
  },
  "status": 200
}

Returns one booking of yours: who booked, when, the answers to your form, the payment and, for a paid booking, what can still be refunded (refundable_payments) and the refund log (refund_log). Use it to check a booking after you made, moved or canceled it.

HTTP Request

GET https://timelydo.com/api/v1/bookings/<ID>

Find free times

curl "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/slots?from=2026-12-01&to=2026-12-03&timezone=Europe/Berlin" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json"
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/slots')
uri.query = URI.encode_www_form(from: '2026-12-01', to: '2026-12-03', timezone: 'Europe/Berlin')

request = Net::HTTP::Get.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  puts "#{body['data']['days'].values.sum(&:size)} free times"
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.get(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/slots",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    params={"from": "2026-12-01", "to": "2026-12-03", "timezone": "Europe/Berlin"},
    timeout=10,
)

body = response.json()

if body["success"]:
    print(sum(len(day) for day in body["data"]["days"].values()), "free times")
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function findFreeTimes() {
  const response = await fetch("https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/slots?from=2026-12-01&to=2026-12-03&timezone=Europe/Berlin", {
    headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json" },
  });

  const body = await response.json();

  if (body.success) {
    console.log(`${Object.values(body.data.days).flat().length} free times`);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

findFreeTimes();

The response (trimmed):

{
  "success": true,
  "message": "Slots fetched",
  "data": {
    "event_id": "7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90",
    "timezone": "Europe/Berlin",
    "days": {
      "2026-12-01": [
        {
          "start_at": "2026-12-01T10:00:00+01:00",
          "end_at": "2026-12-01T10:30:00+01:00"
        },
        {
          "start_at": "2026-12-01T10:30:00+01:00",
          "end_at": "2026-12-01T11:00:00+01:00"
        }
      ],
      "2026-12-02": [
        {
          "start_at": "2026-12-02T10:00:00+01:00",
          "end_at": "2026-12-02T10:30:00+01:00"
        }
      ]
    }
  },
  "status": 200
}

Lists the times a client could book on this event, exactly as the public booking page offers them: the working hours of the event's availability calendar, holidays and date changes, the host's busy times and connected calendars, other bookings, padding, the minimum notice, the booking window and the daily and weekly limits. A day without free times is left out.

Times are shown in the zone you ask for, with its offset, so a client in another zone reads the right hour. Days are the client's days in that zone.

HTTP Request

GET https://timelydo.com/api/v1/events/<ID>/slots

Query parameters

Parameter Default Description
from today First day, as YYYY-MM-DD.
to from plus 13 days Last day, as YYYY-MM-DD. At most 62 days after from, otherwise 422.
timezone your time zone An IANA name such as Europe/Berlin. An unknown name returns 422.
duration the event length A length in minutes, when the event offers more than one length. Any other value falls back to the event length.

An event that is switched off, deleted or not yours returns 404.

Book a client

curl -X POST "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/bookings" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"start_at": "2026-12-01T09:00:00Z", "attendee": {"full_name": "Sam Rivera", "email": "sam@example.com", "timezone": "Europe/Berlin"}}'
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/bookings')

request = Net::HTTP::Post.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request.body = { start_at: '2026-12-01T09:00:00Z', attendee: { full_name: 'Sam Rivera', email: 'sam@example.com', timezone: 'Europe/Berlin' } }.to_json

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  puts "#{body['data']['id']} #{body['data']['status']}"
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.post(
    "https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/bookings",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    json={
        "start_at": "2026-12-01T09:00:00Z",
        "attendee": {
            "full_name": "Sam Rivera",
            "email": "sam@example.com",
            "timezone": "Europe/Berlin"
        }
    },
    timeout=10,
)

body = response.json()

if body["success"]:
    print(body["data"]["id"], body["data"]["status"])
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function bookClient() {
  const response = await fetch("https://timelydo.com/api/v1/events/7a1c33e2-4b8d-4f6a-9c21-5d0b7f4a1e90/bookings", {
    method: "POST",
    headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json", "Content-Type": "application/json" },
    body: JSON.stringify({"start_at": "2026-12-01T09:00:00Z", "attendee": {"full_name": "Sam Rivera", "email": "sam@example.com", "timezone": "Europe/Berlin"}}),
  });

  const body = await response.json();

  if (body.success) {
    console.log(`${body.data.id} ${body.data.status}`);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

bookClient();

The response (trimmed):

{
  "success": true,
  "message": "The booking is made and the client has been sent a confirmation.",
  "data": {
    "id": "b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915",
    "status": "confirmed",
    "event_name": "Intro call",
    "color": "peacock",
    "attendee_name": "Sam Rivera",
    "attendee_email": "sam@example.com",
    "attendee_timezone_titleize": "Europe/Berlin",
    "host_ui_booking_range_with_date": "Wednesday, 23 September 2026, 10:00am to 10:30am",
    "ui_duration": "30 Minutes",
    "booking_url": "https://timelydo.com/jane/intro-call/bookings/b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915",
    "can_be_rescheduled": true,
    "outcome": null,
    "canceled_reason": null,
    "answers": [],
    "private_key": "8c2f5b19-77ad-4e30-9c6b-1f0a2d3e4b58"
  },
  "status": 200
}

Books a meeting for a client on one of your events, as you would on the booking page. The booking is confirmed at once, marked as made by the host, and the client gets the confirmation email with the links to move or cancel it. Your calendar, connected calendars, webhooks and the activity log are updated the same way as for any booking.

The time must be one of the free times of Find free times, except that you are not held to the minimum notice, the booking window or the daily and weekly limits of the event (the host decides). A time that is not free, or already booked, returns 422 with error_code slot_unavailable. Two calls for the same time never both succeed.

An event that asks for approval is booked as confirmed all the same, since you are the one who approves.

An event that charges money is booked as confirmed at once and the client owes the full price as a balance. Choose how with payment: due (the default) mails the client a link to pay, and paid_in_person marks the booking as paid because you already have the money. due returns 422 with error_code payment_unavailable when your Stripe account cannot take payments yet, and nothing is booked. Any other value returns 422. A free event ignores payment.

HTTP Request

POST https://timelydo.com/api/v1/events/<ID>/bookings

Body parameters

Parameter Required Description
start_at yes When the meeting starts, ISO 8601 with a zone, for example 2026-12-01T09:00:00Z. Anything else returns 400.
attendee[email] yes The client's email address. The same person is found again by email, so bookings add up under one attendee.
attendee[full_name] no The client's name. Taken from the email when left out.
attendee[timezone] no An IANA name. The client's confirmation is written in this zone. Defaults to your time zone.
attendee[time_format] no 12_hours or 24_hours. Defaults to yours.
duration no Length in minutes when the event offers more than one length. Defaults to the event length.
guest_emails no A list of extra guests who also get the invitation.
payment no For a paid event only: due (default, the client is mailed a link to pay) or paid_in_person (you took the money, the booking is marked paid).
answers no Answers to the event's form: a list of { "event_question_id": "...", "answer": "..." }. The event's form rules apply: a required question without an answer returns 422.

Reschedule a booking

curl -X POST "https://timelydo.com/api/v1/bookings/b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915/reschedule" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"start_at": "2026-12-02T09:00:00Z"}'
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/bookings/b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915/reschedule')

request = Net::HTTP::Post.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request.body = { start_at: '2026-12-02T09:00:00Z' }.to_json

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  puts "#{body['data']['id']} #{body['data']['status']}"
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.post(
    "https://timelydo.com/api/v1/bookings/b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915/reschedule",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    json={
        "start_at": "2026-12-02T09:00:00Z"
    },
    timeout=10,
)

body = response.json()

if body["success"]:
    print(body["data"]["id"], body["data"]["status"])
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function rescheduleBooking() {
  const response = await fetch("https://timelydo.com/api/v1/bookings/b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915/reschedule", {
    method: "POST",
    headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json", "Content-Type": "application/json" },
    body: JSON.stringify({"start_at": "2026-12-02T09:00:00Z"}),
  });

  const body = await response.json();

  if (body.success) {
    console.log(`${body.data.id} ${body.data.status}`);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

rescheduleBooking();

The response (trimmed):

{
  "success": true,
  "message": "The booking is moved and the client has been sent the new time.",
  "data": {
    "id": "d8a2e3f4-1c5b-4a97-8e60-3b9c4d5e6f70",
    "status": "confirmed",
    "event_name": "Intro call",
    "color": "peacock",
    "attendee_name": "Sam Rivera",
    "attendee_email": "sam@example.com",
    "attendee_timezone_titleize": "Europe/Berlin",
    "host_ui_booking_range_with_date": "Thursday, 24 September 2026, 10:00am to 10:30am",
    "ui_duration": "30 Minutes",
    "booking_url": "https://timelydo.com/jane/intro-call/bookings/b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915",
    "can_be_rescheduled": true,
    "outcome": null,
    "canceled_reason": null,
    "answers": [],
    "private_key": "8c2f5b19-77ad-4e30-9c6b-1f0a2d3e4b58",
    "is_rescheduled": true
  },
  "status": 200
}

Moves a confirmed booking to another time. As on the page the booker uses, the old booking becomes rescheduled and a new booking is created for the new time with the same client, guests, answers, location and any deposit balance. The response is the new booking: use its id from now on. The client gets the email with the new time. The old booking is kept, and the new one points back at it.

Unlike the client, you are not held to the event's reschedule cutoff or minimum notice, but the new time must be free (see Find free times), otherwise 422 is returned and nothing changes. Only a confirmed booking that has not happened yet can be moved: a request, a canceled, past or already rescheduled booking returns 422. Money already paid stays with the booking; nothing is charged or refunded.

HTTP Request

POST https://timelydo.com/api/v1/bookings/<ID>/reschedule

Body parameters

Parameter Required Description
start_at yes The new start, ISO 8601 with a zone. Anything else returns 400.
duration no A new length in minutes, if the event offers it. Defaults to the current length.
reason no Why it was moved, up to 500 characters.

Cancel a booking

curl -X DELETE "https://timelydo.com/api/v1/bookings/b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"canceled_reason": "I have to travel"}'
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/bookings/b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915')

request = Net::HTTP::Delete.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request.body = { canceled_reason: 'I have to travel' }.to_json

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  puts "#{body['data']['status']} refunding: #{body['data']['refunding']}"
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.delete(
    "https://timelydo.com/api/v1/bookings/b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    json={
        "canceled_reason": "I have to travel"
    },
    timeout=10,
)

body = response.json()

if body["success"]:
    print(body["data"]["status"], "refunding:", body["data"]["refunding"])
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function cancelBooking() {
  const response = await fetch("https://timelydo.com/api/v1/bookings/b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915", {
    method: "DELETE",
    headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json", "Content-Type": "application/json" },
    body: JSON.stringify({"canceled_reason": "I have to travel"}),
  });

  const body = await response.json();

  if (body.success) {
    console.log(`${body.data.status} refunding: ${body.data.refunding}`);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

cancelBooking();

The response (trimmed):

{
  "success": true,
  "message": "The booking is canceled.",
  "data": {
    "id": "b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915",
    "status": "canceled",
    "event_name": "Intro call",
    "color": "peacock",
    "attendee_name": "Sam Rivera",
    "attendee_email": "sam@example.com",
    "attendee_timezone_titleize": "Europe/Berlin",
    "host_ui_booking_range_with_date": "Wednesday, 23 September 2026, 10:00am to 10:30am",
    "ui_duration": "30 Minutes",
    "booking_url": "https://timelydo.com/jane/intro-call/bookings/b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915",
    "can_be_rescheduled": false,
    "outcome": null,
    "canceled_reason": "I have to travel",
    "answers": [],
    "private_key": "8c2f5b19-77ad-4e30-9c6b-1f0a2d3e4b58",
    "refunding": false
  },
  "status": 200
}

Cancels a booking as the host. The booking is kept with the status canceled (the client holds links to it), the client gets the cancelation email, the slot is free again and your connected calendar event is removed.

If the booking was paid, a cancel by the host refunds nothing unless you say so: send refund set to full and the whole payment goes back, Timelydo's fee included (the refund schedule of the event is what a client gets when they cancel themselves, it does not apply to a cancel by the host). refunding in the response is true when a refund was started, and the refund itself is made a moment later in the background. A refund of only part of a payment is made in the app. Only a booking that can still be canceled (confirmed or waiting for your answer, not over, not already canceled) is canceled: anything else returns 422.

HTTP Request

DELETE https://timelydo.com/api/v1/bookings/<ID>

Body parameters

Parameter Required Description
canceled_reason no A message the client reads in the cancelation email.
refund no full to refund a paid booking in full. Without it nothing is refunded.

After the meeting

Record what happened with Mark a meeting completed or no show. outcome is completed or no_show, shown on the booking and in your analytics. A request waiting for you is answered with approve or decline.

Webhooks

A webhook tells your server when something happens to a booking. Timelydo sends an HTTP POST with a JSON body to the address you choose, signs it so you can prove it is ours, tries again when your server fails, and keeps a log of every attempt that you can read and replay.

Events

Event Sent when
bookings.created A booking is made and confirmed.
bookings.requested A booking waits for your answer.
bookings.confirmed A booking that waited for payment is paid.
bookings.approved You said yes to a request.
bookings.declined You said no to a request.
bookings.expired A request ran out of time.
bookings.canceled A booking is canceled.
bookings.rescheduled A booking is moved. The new booking sends bookings.created.
bookings.completed A meeting is marked completed.
bookings.no_show A meeting is marked as a no show.
webhooks.test You pressed Test.

By default an endpoint gets every booking event. Choose event_types to receive only some.

A request body:

{
  "id": "5f0e8c1a-2b7d-4c39-8a64-1d9e3b7a0c52",
  "name": "bookings.canceled",
  "created_at": "2026-11-02T17:57:58.120Z",
  "payload": { "...": "the booking, as in the Bookings section" }
}

Every request has these headers:

Header Value
Content-Type application/json
User-Agent Timelydo-Webhooks/1.0
X-Timelydo-Event The event name, for example bookings.canceled.
X-Timelydo-Delivery Identifies the delivery. The same for every retry of it.
X-Timelydo-Signature t=<unix time>,v1=<hex>

Answer with any 2xx status within 10 seconds. Anything else, or no answer, counts as a failure.

Verify the signature

Check X-Timelydo-Signature before you trust a request. The header looks like t=1700000000,v1=aecbc681.... Compute the HMAC SHA256 of the text <t>.<raw body> with your signing secret, as lowercase hex, and compare it with every v1 value in the header in constant time. Use the raw body, not a parsed and rewritten one, and refuse a t that is more than five minutes old.

While a rotated secret is still valid the header carries two v1 values. Accept the request when either matches.

The samples check a known example. They print valid:

SECRET="whsec_test_secret"
BODY='{"a":1}'
T=1700000000
EXPECTED=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
HEADER="t=$T,v1=aecbc681fe3d2604c08ff8e880a0988d997a12367fcf9545e6a3b90c1dfc04b7"

case "$HEADER" in
  *"v1=$EXPECTED"*) echo "valid" ;;
  *) echo "invalid" ;;
esac
require 'openssl'

secret = 'whsec_test_secret'
body = '{"a":1}'
header = 't=1700000000,v1=aecbc681fe3d2604c08ff8e880a0988d997a12367fcf9545e6a3b90c1dfc04b7'

timestamp, *signatures = header.split(',').map { |part| part.split('=', 2).last }
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{timestamp}.#{body}")
fresh = true # in production: (Time.now.to_i - timestamp.to_i).abs <= 300

# Compare without stopping at the first different character.
same = ->(a, b) { a.bytesize == b.bytesize && a.bytes.zip(b.bytes).sum { |x, y| x ^ y }.zero? }

valid = fresh && signatures.any? { |signature| same.call(signature, expected) }
puts valid ? 'valid' : 'invalid'
import hashlib
import hmac

secret = "whsec_test_secret"
body = '{"a":1}'
header = "t=1700000000,v1=aecbc681fe3d2604c08ff8e880a0988d997a12367fcf9545e6a3b90c1dfc04b7"

parts = dict(item.split("=", 1) for item in header.split(","))
signatures = [item.split("=", 1)[1] for item in header.split(",") if item.startswith("v1=")]
expected = hmac.new(secret.encode(), f"{parts['t']}.{body}".encode(), hashlib.sha256).hexdigest()
# In production also check: abs(time.time() - int(parts["t"])) <= 300

print("valid" if any(hmac.compare_digest(s, expected) for s in signatures) else "invalid")
// Node.js 18 or newer.
const crypto = require("crypto");

const secret = "whsec_test_secret";
const body = '{"a":1}';
const header = "t=1700000000,v1=aecbc681fe3d2604c08ff8e880a0988d997a12367fcf9545e6a3b90c1dfc04b7";

const items = header.split(",").map((item) => item.split("="));
const timestamp = items.find(([key]) => key === "t")[1];
const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex");
// In production also check: Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300

const valid = items
  .filter(([key]) => key === "v1")
  .some(([, value]) => value.length === expected.length && crypto.timingSafeEqual(Buffer.from(value), Buffer.from(expected)));

console.log(valid ? "valid" : "invalid");

Retries and the delivery log

When your server fails, Timelydo tries again after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours. That is six attempts in all, and then the delivery is marked failed. Each attempt is a row in the delivery log, kept for 30 days. An address that points to a private network, such as localhost, 10.x.x.x, 172.16.x.x to 172.31.x.x, 192.168.x.x or 169.254.x.x, is never called and is not retried.

Your signing secret starts with whsec_. Keep it on your server and out of your code repository. Because a retry can arrive after your server already handled the first try, use X-Timelydo-Delivery to ignore a delivery you have seen.

The webhook object

A webhook:

{
  "id": "c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61",
  "name": "Order system",
  "url": "https://example.com/timelydo",
  "active": true,
  "booking_included": true,
  "event_included": false,
  "event_types": ["bookings.created", "bookings.canceled"],
  "secret_hint": "d5fe",
  "previous_secret_expires_at": null,
  "created_at_ago": "4 hrs ago",
  "updated_at_ago": "1 min ago"
}
Field Type Description
id string Unique id.
name string Your label for the endpoint.
url string Where requests are sent.
active boolean Switched off endpoints receive nothing.
booking_included boolean Whether booking events are sent.
event_types array The chosen booking events. Empty means all.
secret_hint string or null The last four characters of the signing secret. The secret itself is never returned again.
previous_secret_expires_at string or null Until when the old secret also signs, after a rotation.

List webhooks

curl -X GET "https://timelydo.com/api/v1/integrations/webhooks" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json"
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/integrations/webhooks')

request = Net::HTTP::Get.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

puts body['success'] ? body['message'] : "#{response.code}: #{body['message']}"
import os
import requests

response = requests.get(
    "https://timelydo.com/api/v1/integrations/webhooks",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    timeout=10,
)

body = response.json()
print(body["message"] if body["success"] else f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function listWebhooks() {
  const response = await fetch(
    "https://timelydo.com/api/v1/integrations/webhooks",
    { method: "GET", headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json" } },
  );

  const body = await response.json();

  if (body.success) {
    console.log(body.message);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

listWebhooks();

Returns every webhook endpoint of your account, newest first.

HTTP Request

GET https://timelydo.com/api/v1/integrations/webhooks

Create a webhook

curl -X POST "https://timelydo.com/api/v1/integrations/webhooks" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"webhook": {"name": "Order system", "url": "https://example.com/timelydo", "booking_included": true, "event_types": ["bookings.created", "bookings.canceled"]}}'
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/integrations/webhooks')

request = Net::HTTP::Post.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request.body = JSON.generate({ webhook: { name: 'Order system', url: 'https://example.com/timelydo', booking_included: true, event_types: ['bookings.created', 'bookings.canceled'] } })

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

puts body['success'] ? body['message'] : "#{response.code}: #{body['message']}"
import os
import requests

response = requests.post(
    "https://timelydo.com/api/v1/integrations/webhooks",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    json={"webhook": {"name": "Order system", "url": "https://example.com/timelydo", "booking_included": True, "event_types": ["bookings.created", "bookings.canceled"]}},
    timeout=10,
)

body = response.json()
print(body["message"] if body["success"] else f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function createWebhook() {
  const response = await fetch(
    "https://timelydo.com/api/v1/integrations/webhooks",
    { method: "POST", headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json", "Content-Type": "application/json" }, body: JSON.stringify({"webhook": {"name": "Order system", "url": "https://example.com/timelydo", "booking_included": true, "event_types": ["bookings.created", "bookings.canceled"]}}) },
  );

  const body = await response.json();

  if (body.success) {
    console.log(body.message);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

createWebhook();

The response also carries the signing secret. This is the only time it is shown:

{
  "success": true,
  "message": "Webhook is added",
  "data": {
    "webhook": {
      "id": "c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61",
      "name": "Order system",
      "url": "https://example.com/timelydo",
      "active": true,
      "booking_included": true,
      "event_included": false,
      "event_types": [
        "bookings.created",
        "bookings.canceled"
      ],
      "secret_hint": "d5fe",
      "previous_secret_expires_at": null,
      "created_at_ago": "4 hrs ago",
      "updated_at_ago": "1 min ago"
    },
    "signing_secret": "whsec_c54cec431cf4617fe63b96ab0d2a9785cbf4897d7b68d5fe"
  }
}

Creates an endpoint and its signing secret. Save the secret now, because it is never shown again. If you lose it, rotate it.

HTTP Request

POST https://timelydo.com/api/v1/integrations/webhooks

Body parameters

Parameter Type Description
name string Required. Up to 30 characters.
url string Required. A public http:// or https:// address. Addresses on a private network are refused.
booking_included boolean Send booking events. Default false.
event_types array of strings Which booking events to send, for example ["bookings.canceled"]. Leave empty or leave out for all of them.
active boolean Default true.

Update a webhook

curl -X PUT "https://timelydo.com/api/v1/integrations/webhooks/c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"webhook": {"event_types": ["bookings.canceled"], "active": true}}'
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/integrations/webhooks/c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61')

request = Net::HTTP::Put.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request.body = JSON.generate({ webhook: { event_types: ['bookings.canceled'], active: true } })

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

puts body['success'] ? body['message'] : "#{response.code}: #{body['message']}"
import os
import requests

response = requests.put(
    "https://timelydo.com/api/v1/integrations/webhooks/c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    json={"webhook": {"event_types": ["bookings.canceled"], "active": True}},
    timeout=10,
)

body = response.json()
print(body["message"] if body["success"] else f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function updateWebhook() {
  const response = await fetch(
    "https://timelydo.com/api/v1/integrations/webhooks/c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61",
    { method: "PUT", headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json", "Content-Type": "application/json" }, body: JSON.stringify({"webhook": {"event_types": ["bookings.canceled"], "active": true}}) },
  );

  const body = await response.json();

  if (body.success) {
    console.log(body.message);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

updateWebhook();

Changes the name, address, event types or the active switch. Send only what changes.

HTTP Request

PUT https://timelydo.com/api/v1/integrations/webhooks/<ID>

Rotate the signing secret

curl -X POST "https://timelydo.com/api/v1/integrations/webhooks/c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61/rotate_secret" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"keep_previous": true}'
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/integrations/webhooks/c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61/rotate_secret')

request = Net::HTTP::Post.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request.body = JSON.generate({ keep_previous: true })

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

puts body['success'] ? body['message'] : "#{response.code}: #{body['message']}"
import os
import requests

response = requests.post(
    "https://timelydo.com/api/v1/integrations/webhooks/c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61/rotate_secret",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    json={"keep_previous": True},
    timeout=10,
)

body = response.json()
print(body["message"] if body["success"] else f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function rotateSecret() {
  const response = await fetch(
    "https://timelydo.com/api/v1/integrations/webhooks/c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61/rotate_secret",
    { method: "POST", headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json", "Content-Type": "application/json" }, body: JSON.stringify({"keep_previous": true}) },
  );

  const body = await response.json();

  if (body.success) {
    console.log(body.message);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

rotateSecret();

The new secret is in signing_secret, shown once:

{
  "success": true,
  "message": "Webhook signing secret is rotated",
  "data": {
    "webhook": {
      "id": "c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61",
      "name": "Order system",
      "url": "https://example.com/timelydo",
      "active": true,
      "booking_included": true,
      "event_included": false,
      "event_types": [
        "bookings.created",
        "bookings.canceled"
      ],
      "secret_hint": "d5fe",
      "previous_secret_expires_at": "2026-11-03T17:58:07Z",
      "created_at_ago": "4 hrs ago",
      "updated_at_ago": "1 min ago"
    },
    "signing_secret": "whsec_c54cec431cf4617fe63b96ab0d2a9785cbf4897d7b68d5fe"
  }
}

Makes a new signing secret. With keep_previous set to true the old secret keeps signing next to the new one for 24 hours, so you can switch your server without dropping a delivery. Without it the old secret stops at once.

HTTP Request

POST https://timelydo.com/api/v1/integrations/webhooks/<ID>/rotate_secret

Send a test event

curl -X POST "https://timelydo.com/api/v1/integrations/webhooks/test_webhook" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"id": "c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61"}'
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/integrations/webhooks/test_webhook')

request = Net::HTTP::Post.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request.body = JSON.generate({ id: 'c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61' })

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

puts body['success'] ? body['message'] : "#{response.code}: #{body['message']}"
import os
import requests

response = requests.post(
    "https://timelydo.com/api/v1/integrations/webhooks/test_webhook",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    json={"id": "c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61"},
    timeout=10,
)

body = response.json()
print(body["message"] if body["success"] else f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function testWebhook() {
  const response = await fetch(
    "https://timelydo.com/api/v1/integrations/webhooks/test_webhook",
    { method: "POST", headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json", "Content-Type": "application/json" }, body: JSON.stringify({"id": "c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61"}) },
  );

  const body = await response.json();

  if (body.success) {
    console.log(body.message);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

testWebhook();

Sends a signed webhooks.test event to the endpoint and puts it in the delivery log.

HTTP Request

POST https://timelydo.com/api/v1/integrations/webhooks/test_webhook

List deliveries

curl -X GET "https://timelydo.com/api/v1/integrations/webhooks/c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61/deliveries" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json"
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/integrations/webhooks/c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61/deliveries')

request = Net::HTTP::Get.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

puts body['success'] ? body['message'] : "#{response.code}: #{body['message']}"
import os
import requests

response = requests.get(
    "https://timelydo.com/api/v1/integrations/webhooks/c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61/deliveries",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    timeout=10,
)

body = response.json()
print(body["message"] if body["success"] else f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function listDeliveries() {
  const response = await fetch(
    "https://timelydo.com/api/v1/integrations/webhooks/c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61/deliveries",
    { method: "GET", headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json" } },
  );

  const body = await response.json();

  if (body.success) {
    console.log(body.message);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

listDeliveries();

A delivery attempt:

{
  "id": "e5b7a2c4-1d93-4f60-8a2b-9c4d6e8f0a13",
  "delivery_key": "2b03ecb4-5d57-4848-a038-d3895006489b",
  "event_name": "bookings.canceled",
  "attempt": 1,
  "status": "success",
  "response_code": 200,
  "duration_ms": 182,
  "error": null,
  "replay_of_id": null,
  "created_at": "2026-11-02T17:57:58Z",
  "attempted_at": "2026-11-02T17:57:59Z",
  "scheduled_for": "2026-11-02T17:57:58Z",
  "max_attempts": 6
}

Every attempt to send an event, newest first, 25 at a time. Add page=2 for more and status=failed (or success, retrying, pending) to filter. The response has has_more when there is another page.

Field Type Description
id string The attempt.
delivery_key string Shared by every attempt of one delivery. Also sent as X-Timelydo-Delivery.
event_name string For example bookings.canceled.
attempt integer 1 to max_attempts.
status string pending, success, retrying (failed, another try is scheduled) or failed (no tries left).
response_code integer or null Your server's HTTP status.
duration_ms integer or null How long your server took.
error string or null Why it failed, for example HTTP 500.
replay_of_id string or null Set when this delivery is a replay.
scheduled_for string When the attempt is or was due.

HTTP Request

GET https://timelydo.com/api/v1/integrations/webhooks/<ID>/deliveries

Get a delivery

curl -X GET "https://timelydo.com/api/v1/integrations/webhooks/c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61/deliveries/e5b7a2c4-1d93-4f60-8a2b-9c4d6e8f0a13" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json"
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/integrations/webhooks/c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61/deliveries/e5b7a2c4-1d93-4f60-8a2b-9c4d6e8f0a13')

request = Net::HTTP::Get.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

puts body['success'] ? body['message'] : "#{response.code}: #{body['message']}"
import os
import requests

response = requests.get(
    "https://timelydo.com/api/v1/integrations/webhooks/c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61/deliveries/e5b7a2c4-1d93-4f60-8a2b-9c4d6e8f0a13",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    timeout=10,
)

body = response.json()
print(body["message"] if body["success"] else f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function getDelivery() {
  const response = await fetch(
    "https://timelydo.com/api/v1/integrations/webhooks/c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61/deliveries/e5b7a2c4-1d93-4f60-8a2b-9c4d6e8f0a13",
    { method: "GET", headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json" } },
  );

  const body = await response.json();

  if (body.success) {
    console.log(body.message);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

getDelivery();

Returns one attempt with request_body (exactly what was signed and sent) and response_body (the first 2000 characters of your server's answer).

HTTP Request

GET https://timelydo.com/api/v1/integrations/webhooks/<ID>/deliveries/<DELIVERY_ID>

Replay a delivery

curl -X POST "https://timelydo.com/api/v1/integrations/webhooks/c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61/deliveries/e5b7a2c4-1d93-4f60-8a2b-9c4d6e8f0a13/replay" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json"
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/integrations/webhooks/c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61/deliveries/e5b7a2c4-1d93-4f60-8a2b-9c4d6e8f0a13/replay')

request = Net::HTTP::Post.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

puts body['success'] ? body['message'] : "#{response.code}: #{body['message']}"
import os
import requests

response = requests.post(
    "https://timelydo.com/api/v1/integrations/webhooks/c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61/deliveries/e5b7a2c4-1d93-4f60-8a2b-9c4d6e8f0a13/replay",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    timeout=10,
)

body = response.json()
print(body["message"] if body["success"] else f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function replayDelivery() {
  const response = await fetch(
    "https://timelydo.com/api/v1/integrations/webhooks/c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61/deliveries/e5b7a2c4-1d93-4f60-8a2b-9c4d6e8f0a13/replay",
    { method: "POST", headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json" } },
  );

  const body = await response.json();

  if (body.success) {
    console.log(body.message);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

replayDelivery();

Sends the same payload again as a new delivery with its own attempts and a fresh signature timestamp. Use it after you fixed your server.

HTTP Request

POST https://timelydo.com/api/v1/integrations/webhooks/<ID>/deliveries/<DELIVERY_ID>/replay

Delete a webhook

curl -X DELETE "https://timelydo.com/api/v1/integrations/webhooks/c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json"
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/integrations/webhooks/c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61')

request = Net::HTTP::Delete.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

puts body['success'] ? body['message'] : "#{response.code}: #{body['message']}"
import os
import requests

response = requests.delete(
    "https://timelydo.com/api/v1/integrations/webhooks/c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    timeout=10,
)

body = response.json()
print(body["message"] if body["success"] else f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function deleteWebhook() {
  const response = await fetch(
    "https://timelydo.com/api/v1/integrations/webhooks/c3a9d1f2-6b47-4e08-9a15-7d2e4b8c0f61",
    { method: "DELETE", headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json" } },
  );

  const body = await response.json();

  if (body.success) {
    console.log(body.message);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

deleteWebhook();

Removes the endpoint and its delivery log.

HTTP Request

DELETE https://timelydo.com/api/v1/integrations/webhooks/<ID>

Embed widget

Put your booking page on your own website with one script tag. The widget shows the same page your visitors see on Timelydo, in your booking design, either inline or in a popup opened by a button. Your page can listen to typed events: viewed, slot picked and booked.

This part needs no API key. It runs in the visitor's browser.

Add the widget

A popup button:

<script src="https://timelydo.com/embed.js"
  data-event="https://timelydo.com/jane/intro-call"
  data-mode="popup"
  data-label="Book a call"
  data-color="#2563eb"
  async></script>

An inline widget in a place you choose:

<div id="booking"></div>
<script src="https://timelydo.com/embed.js"
  data-event="https://timelydo.com/jane/intro-call"
  data-mode="inline"
  data-target="#booking"
  async></script>
Attribute Required Description
data-event yes The public booking page address of your event.
data-mode no inline (default) shows the page in the place of the tag. popup shows a button that opens the page in a window.
data-target no A CSS selector. The widget or button is added inside it. Without it, it is added right after the script tag.
data-label no The text of the popup button. Defaults to Book a meeting.
data-color no The popup button colour as a hex value such as #2563eb. The booking page itself follows your booking design.
data-locale no A language code such as de. Without it the visitor's language is used.
data-gtm no true pushes the events to window.dataLayer for Google Tag Manager.

The popup closes with the close button, the ESC key or a click outside the window, keeps the focus inside while it is open, stops the page behind it from scrolling and fills the screen on a phone. Call Timelydo.open() to open it from your own button. The inline widget grows with the page inside it.

Listen to events

Timelydo.on("booked", function (event) {
  console.log("Booked", event.booking_id, event.event);
});

An event as your handler receives it:

{
  "source": "timelydo",
  "type": "timelydo:booked",
  "schema_version": 1,
  "event": "intro-call",
  "booking_id": "b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915"
}
Name Sent when Extra fields
viewed The booking page has loaded. none
slot_picked The visitor chose a time. none
booked A booking was made. booking_id
resize The page height changed. height in pixels

Every event has source (timelydo), type (timelydo: plus the name), schema_version (now 1) and event, the slug of your event. No name, email or answer is ever included. Register with Timelydo.on(name, handler) and remove with Timelydo.off(name, handler). A new field may be added in the same schema_version; a change that could break your code gets a new one.

A paid event is completed on a payment page, so booked is not sent for it yet.

Messages without the loader

If you use your own iframe, the page at https://timelydo.com/embed/<user>/<event> sends the same objects with postMessage to the page that holds it. Add ?parent_origin= with your own origin, URL encoded, so the page knows where to send them. Timelydo never uses * as the target. Check event.origin is https://timelydo.com and event.source is your iframe before you trust a message.

Google Tag Manager

Add data-gtm="true" and every event except resize is pushed to window.dataLayer.

// What lands in the dataLayer after a booking:
{ event: "timelydo_booked", schema_version: 1, event_slug: "intro-call", booking_id: "b4f1c07a-52d8-4e6b-93a1-6c28d0e7f915" }

The GTM event name is timelydo_viewed, timelydo_slot_picked or timelydo_booked. In GTM create a Custom Event trigger with that name and attach your tags. The slug is sent as event_slug so it does not clash with GTM's own event key.

Availability

Read and change the hours you can be booked: your availability calendars. An availability calendar is a named set of weekly hours plus changes for single dates (a day off, shorter hours). One of them is your default, and every event uses the default unless you picked another one for it. To see the actual free times a client would be offered, use Find free times.

The availability calendar object

An availability calendar:

{
  "id": "9d3e5c71-6a24-4b8f-8e03-2f7c1a9b5d46",
  "name": "Default",
  "default": true,
  "weekly": {
    "0": [],
    "1": [
      {
        "start_minute": 540,
        "end_minute": 1020
      }
    ],
    "2": [
      {
        "start_minute": 540,
        "end_minute": 1020
      }
    ],
    "3": [
      {
        "start_minute": 540,
        "end_minute": 1020
      }
    ],
    "4": [
      {
        "start_minute": 540,
        "end_minute": 1020
      }
    ],
    "5": [
      {
        "start_minute": 540,
        "end_minute": 1020
      }
    ],
    "6": []
  },
  "overrides": [],
  "time_zone": {
    "name": "Europe/Berlin",
    "offset": "+01:00"
  },
  "events_count": 3
}
Field Type Description
id string Unique id of the calendar.
name string The name you gave it.
default boolean true for the calendar your events use unless they pick another.
weekly object The weekly hours, keyed by weekday: 0 is Sunday, 1 Monday, up to 6 Saturday. Each day is a list of windows. An empty list is a closed day.
overrides array The changes for single dates from today on. Each has date, closed and the windows that apply on that date.
time_zone object The zone the minutes are meant in: your own time zone, with its name and current offset.
events_count integer How many of your events use this calendar.

A window is { "start_minute": 540, "end_minute": 1020 }: minutes after midnight in your time zone, so 540 is 9:00 and 1020 is 17:00. A day can have several windows (a lunch break is two windows). Windows are between 0 and 1440, end after they start and may not overlap.

List availability calendars

curl "https://timelydo.com/api/v1/availability_schedules" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json"
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/availability_schedules')

request = Net::HTTP::Get.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  puts "#{body['data']['schedules'].size} calendars"
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.get(
    "https://timelydo.com/api/v1/availability_schedules",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    timeout=10,
)

body = response.json()

if body["success"]:
    print(len(body["data"]["schedules"]), "calendars")
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function listAvailabilityCalendars() {
  const response = await fetch("https://timelydo.com/api/v1/availability_schedules", {
    headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json" },
  });

  const body = await response.json();

  if (body.success) {
    console.log(`${body.data.schedules.length} calendars`);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

listAvailabilityCalendars();

The response (trimmed):

{
  "success": true,
  "message": "ok",
  "data": {
    "schedules": [
      {
        "id": "9d3e5c71-6a24-4b8f-8e03-2f7c1a9b5d46",
        "name": "Default",
        "default": true
      },
      {
        "id": "2b7e1f40-93c5-4d68-a1e2-7c40d5b8f319",
        "name": "Summer hours",
        "default": false
      }
    ],
    "time_zone": {
      "name": "Europe/Berlin",
      "offset": "+01:00"
    },
    "limit": 5
  },
  "status": "ok"
}

Lists your availability calendars, the default first. limit is how many calendars an account may have.

HTTP Request

GET https://timelydo.com/api/v1/availability_schedules

Get an availability calendar

curl "https://timelydo.com/api/v1/availability_schedules/9d3e5c71-6a24-4b8f-8e03-2f7c1a9b5d46" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json"
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/availability_schedules/9d3e5c71-6a24-4b8f-8e03-2f7c1a9b5d46')

request = Net::HTTP::Get.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  puts "#{body['data']['name']} #{body['data']['weekly']["1"]}"
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.get(
    "https://timelydo.com/api/v1/availability_schedules/9d3e5c71-6a24-4b8f-8e03-2f7c1a9b5d46",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    timeout=10,
)

body = response.json()

if body["success"]:
    print(body["data"]["name"], body["data"]["weekly"]["1"])
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function getAvailabilityCalendar() {
  const response = await fetch("https://timelydo.com/api/v1/availability_schedules/9d3e5c71-6a24-4b8f-8e03-2f7c1a9b5d46", {
    headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json" },
  });

  const body = await response.json();

  if (body.success) {
    console.log(`${body.data.name} ${JSON.stringify(body.data.weekly["1"])}`);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

getAvailabilityCalendar();

The response:

{
  "success": true,
  "message": "ok",
  "data": {
    "id": "9d3e5c71-6a24-4b8f-8e03-2f7c1a9b5d46",
    "name": "Default",
    "default": true,
    "weekly": {
      "0": [],
      "1": [
        {
          "start_minute": 540,
          "end_minute": 1020
        }
      ],
      "2": [
        {
          "start_minute": 540,
          "end_minute": 1020
        }
      ],
      "3": [
        {
          "start_minute": 540,
          "end_minute": 1020
        }
      ],
      "4": [
        {
          "start_minute": 540,
          "end_minute": 1020
        }
      ],
      "5": [
        {
          "start_minute": 540,
          "end_minute": 1020
        }
      ],
      "6": []
    },
    "overrides": [],
    "time_zone": {
      "name": "Europe/Berlin",
      "offset": "+01:00"
    },
    "events_count": 3
  },
  "status": "ok"
}

Returns the availability calendar with its weekly hours and the date changes from today on. Another account's id returns 404.

HTTP Request

GET https://timelydo.com/api/v1/availability_schedules/<ID>

Preview the days of an availability calendar

curl "https://timelydo.com/api/v1/availability_schedules/9d3e5c71-6a24-4b8f-8e03-2f7c1a9b5d46/preview?from=2026-12-23&to=2026-12-26" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json"
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/availability_schedules/9d3e5c71-6a24-4b8f-8e03-2f7c1a9b5d46/preview')
uri.query = URI.encode_www_form(from: '2026-12-23', to: '2026-12-26')

request = Net::HTTP::Get.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  puts "#{body['data']['days'].size} days"
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.get(
    "https://timelydo.com/api/v1/availability_schedules/9d3e5c71-6a24-4b8f-8e03-2f7c1a9b5d46/preview",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    params={"from": "2026-12-23", "to": "2026-12-26"},
    timeout=10,
)

body = response.json()

if body["success"]:
    print(len(body["data"]["days"]), "days")
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function previewAvailabilityCalendar() {
  const response = await fetch("https://timelydo.com/api/v1/availability_schedules/9d3e5c71-6a24-4b8f-8e03-2f7c1a9b5d46/preview?from=2026-12-23&to=2026-12-26", {
    headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json" },
  });

  const body = await response.json();

  if (body.success) {
    console.log(`${Object.keys(body.data.days).length} days`);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

previewAvailabilityCalendar();

The response (trimmed):

{
  "success": true,
  "message": "ok",
  "data": {
    "days": {
      "2026-12-23": {
        "windows": [
          {
            "start_minute": 540,
            "end_minute": 1020
          }
        ],
        "source": "weekly"
      },
      "2026-12-24": {
        "windows": [],
        "source": "closed"
      },
      "2026-12-25": {
        "windows": [],
        "source": "holiday",
        "holiday": {
          "name": "Christmas Day",
          "color": "tomato"
        }
      },
      "2026-12-26": {
        "windows": [],
        "source": "off"
      }
    }
  },
  "status": "ok"
}

What the calendar says for each day of a range, before bookings and busy times are taken off. source tells why: weekly (the weekly hours), override (hours you set for that date), closed (a date you closed), holiday (one of your holidays) or off (a weekday without hours).

HTTP Request

GET https://timelydo.com/api/v1/availability_schedules/<ID>/preview

Query parameters

Parameter Description
from, to The first and last day, as YYYY-MM-DD. Both are required, to may not be before from, and the range is at most 62 days, otherwise 422.

Change the weekly hours

curl -X PUT "https://timelydo.com/api/v1/availability_schedules/9d3e5c71-6a24-4b8f-8e03-2f7c1a9b5d46" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"availability_schedule": {"weekly": {"0": [], "1": [{"start_minute": 540, "end_minute": 1020}], "2": [{"start_minute": 540, "end_minute": 1020}], "3": [{"start_minute": 540, "end_minute": 1020}], "4": [{"start_minute": 540, "end_minute": 1020}], "5": [{"start_minute": 540, "end_minute": 1020}], "6": []}}}'
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/availability_schedules/9d3e5c71-6a24-4b8f-8e03-2f7c1a9b5d46')

request = Net::HTTP::Put.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request.body = { availability_schedule: { weekly: { '0' => [], '1' => [{ start_minute: 540, end_minute: 1020 }], '2' => [{ start_minute: 540, end_minute: 1020 }], '3' => [{ start_minute: 540, end_minute: 1020 }], '4' => [{ start_minute: 540, end_minute: 1020 }], '5' => [{ start_minute: 540, end_minute: 1020 }], '6' => [] } } }.to_json

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  puts body['data']['weekly']['1']
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.put(
    "https://timelydo.com/api/v1/availability_schedules/9d3e5c71-6a24-4b8f-8e03-2f7c1a9b5d46",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    json={
        "availability_schedule": {
            "weekly": {
                "0": [],
                "1": [
                    {
                        "start_minute": 540,
                        "end_minute": 1020
                    }
                ],
                "2": [
                    {
                        "start_minute": 540,
                        "end_minute": 1020
                    }
                ],
                "3": [
                    {
                        "start_minute": 540,
                        "end_minute": 1020
                    }
                ],
                "4": [
                    {
                        "start_minute": 540,
                        "end_minute": 1020
                    }
                ],
                "5": [
                    {
                        "start_minute": 540,
                        "end_minute": 1020
                    }
                ],
                "6": []
            }
        }
    },
    timeout=10,
)

body = response.json()

if body["success"]:
    print(body["data"]["weekly"]["1"])
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function updateWeeklyHours() {
  const response = await fetch("https://timelydo.com/api/v1/availability_schedules/9d3e5c71-6a24-4b8f-8e03-2f7c1a9b5d46", {
    method: "PUT",
    headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json", "Content-Type": "application/json" },
    body: JSON.stringify({"availability_schedule": {"weekly": {"0": [], "1": [{"start_minute": 540, "end_minute": 1020}], "2": [{"start_minute": 540, "end_minute": 1020}], "3": [{"start_minute": 540, "end_minute": 1020}], "4": [{"start_minute": 540, "end_minute": 1020}], "5": [{"start_minute": 540, "end_minute": 1020}], "6": []}}}),
  });

  const body = await response.json();

  if (body.success) {
    console.log(JSON.stringify(body.data.weekly["1"]));
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

updateWeeklyHours();

The response:

{
  "success": true,
  "message": "ok",
  "data": {
    "id": "9d3e5c71-6a24-4b8f-8e03-2f7c1a9b5d46",
    "name": "Default",
    "default": true,
    "weekly": {
      "0": [],
      "1": [
        {
          "start_minute": 540,
          "end_minute": 1020
        }
      ],
      "2": [
        {
          "start_minute": 540,
          "end_minute": 1020
        }
      ],
      "3": [
        {
          "start_minute": 540,
          "end_minute": 1020
        }
      ],
      "4": [
        {
          "start_minute": 540,
          "end_minute": 1020
        }
      ],
      "5": [
        {
          "start_minute": 540,
          "end_minute": 1020
        }
      ],
      "6": []
    },
    "overrides": [],
    "time_zone": {
      "name": "Europe/Berlin",
      "offset": "+01:00"
    },
    "events_count": 3
  },
  "status": "ok"
}

Replaces the weekly hours of a calendar as a whole, and can rename it. Send every day you want open: a weekday that is missing or has no windows becomes closed. Windows are checked before anything is written, so a bad window (outside 0 to 1440, ending before it starts, overlapping another) returns 422 and changes nothing. Windows that touch are joined into one. New hours apply to free times and to new bookings at once; bookings that exist are not moved or canceled.

HTTP Request

PUT https://timelydo.com/api/v1/availability_schedules/<ID>

Body parameters

Parameter Description
availability_schedule[weekly] The weekly hours, keyed by weekday 0 to 6, each a list of { "start_minute", "end_minute" }. Optional.
availability_schedule[name] A new name. Optional.

Change the hours of single dates

curl -X PUT "https://timelydo.com/api/v1/availability_schedules/9d3e5c71-6a24-4b8f-8e03-2f7c1a9b5d46/overrides" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"dates": ["2026-12-24", "2026-12-31"], "closed": true}'
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/availability_schedules/9d3e5c71-6a24-4b8f-8e03-2f7c1a9b5d46/overrides')

request = Net::HTTP::Put.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request.body = { dates: ['2026-12-24', '2026-12-31'], closed: true }.to_json

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  puts "#{body['data']['overrides'].size} date changes"
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.put(
    "https://timelydo.com/api/v1/availability_schedules/9d3e5c71-6a24-4b8f-8e03-2f7c1a9b5d46/overrides",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    json={
        "dates": [
            "2026-12-24",
            "2026-12-31"
        ],
        "closed": True
    },
    timeout=10,
)

body = response.json()

if body["success"]:
    print(len(body["data"]["overrides"]), "date changes")
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function closeDates() {
  const response = await fetch("https://timelydo.com/api/v1/availability_schedules/9d3e5c71-6a24-4b8f-8e03-2f7c1a9b5d46/overrides", {
    method: "PUT",
    headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json", "Content-Type": "application/json" },
    body: JSON.stringify({"dates": ["2026-12-24", "2026-12-31"], "closed": true}),
  });

  const body = await response.json();

  if (body.success) {
    console.log(`${body.data.overrides.length} date changes`);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

closeDates();

The response:

{
  "success": true,
  "message": "ok",
  "data": {
    "id": "9d3e5c71-6a24-4b8f-8e03-2f7c1a9b5d46",
    "name": "Default",
    "default": true,
    "weekly": {
      "0": [],
      "1": [
        {
          "start_minute": 540,
          "end_minute": 1020
        }
      ],
      "2": [
        {
          "start_minute": 540,
          "end_minute": 1020
        }
      ],
      "3": [
        {
          "start_minute": 540,
          "end_minute": 1020
        }
      ],
      "4": [
        {
          "start_minute": 540,
          "end_minute": 1020
        }
      ],
      "5": [
        {
          "start_minute": 540,
          "end_minute": 1020
        }
      ],
      "6": []
    },
    "overrides": [
      {
        "date": "2026-12-24",
        "closed": true,
        "windows": []
      },
      {
        "date": "2026-12-31",
        "closed": true,
        "windows": []
      }
    ],
    "time_zone": {
      "name": "Europe/Berlin",
      "offset": "+01:00"
    },
    "events_count": 3
  },
  "status": "ok"
}

Gives one or more dates their own hours, replacing what those dates had. Either close them (closed set to true), or give them windows (shorter hours, a late start). Only today and later can be changed. Dates that are not valid, in the past or too many, a bad window, or neither closed nor windows, return 422 and change nothing. Existing bookings on those dates are not touched.

HTTP Request

PUT https://timelydo.com/api/v1/availability_schedules/<ID>/overrides

Body parameters

Parameter Description
dates A list of dates, YYYY-MM-DD.
closed true to close the dates.
windows The hours that apply on those dates when not closed: a list of { "start_minute", "end_minute" }.

Go back to the weekly hours for dates

curl -X DELETE "https://timelydo.com/api/v1/availability_schedules/9d3e5c71-6a24-4b8f-8e03-2f7c1a9b5d46/overrides" \
  -H "X-API-KEY: $TIMELYDO_API_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"dates": ["2026-12-24", "2026-12-31"]}'
require 'net/http'
require 'json'

uri = URI('https://timelydo.com/api/v1/availability_schedules/9d3e5c71-6a24-4b8f-8e03-2f7c1a9b5d46/overrides')

request = Net::HTTP::Delete.new(uri)
request['X-API-KEY'] = ENV.fetch('TIMELYDO_API_KEY')
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request.body = { dates: ['2026-12-24', '2026-12-31'] }.to_json

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end

body = JSON.parse(response.body)

if body['success']
  puts "#{body['data']['overrides'].size} date changes left"
else
  warn "#{response.code}: #{body['message']}"
end
import os
import requests

response = requests.delete(
    "https://timelydo.com/api/v1/availability_schedules/9d3e5c71-6a24-4b8f-8e03-2f7c1a9b5d46/overrides",
    headers={"X-API-KEY": os.environ["TIMELYDO_API_KEY"], "Accept": "application/json"},
    json={
        "dates": [
            "2026-12-24",
            "2026-12-31"
        ]
    },
    timeout=10,
)

body = response.json()

if body["success"]:
    print(len(body["data"]["overrides"]), "date changes left")
else:
    print(f"{response.status_code}: {body['message']}")
// Node.js 18 or newer. Run this on your server, never in a browser.
async function clearDateChanges() {
  const response = await fetch("https://timelydo.com/api/v1/availability_schedules/9d3e5c71-6a24-4b8f-8e03-2f7c1a9b5d46/overrides", {
    method: "DELETE",
    headers: { "X-API-KEY": process.env.TIMELYDO_API_KEY, "Accept": "application/json", "Content-Type": "application/json" },
    body: JSON.stringify({"dates": ["2026-12-24", "2026-12-31"]}),
  });

  const body = await response.json();

  if (body.success) {
    console.log(`${body.data.overrides.length} date changes left`);
  } else {
    console.error(`${response.status}: ${body.message}`);
  }
}

clearDateChanges();

The response:

{
  "success": true,
  "message": "ok",
  "data": {
    "id": "9d3e5c71-6a24-4b8f-8e03-2f7c1a9b5d46",
    "name": "Default",
    "default": true,
    "weekly": {
      "0": [],
      "1": [
        {
          "start_minute": 540,
          "end_minute": 1020
        }
      ],
      "2": [
        {
          "start_minute": 540,
          "end_minute": 1020
        }
      ],
      "3": [
        {
          "start_minute": 540,
          "end_minute": 1020
        }
      ],
      "4": [
        {
          "start_minute": 540,
          "end_minute": 1020
        }
      ],
      "5": [
        {
          "start_minute": 540,
          "end_minute": 1020
        }
      ],
      "6": []
    },
    "overrides": [],
    "time_zone": {
      "name": "Europe/Berlin",
      "offset": "+01:00"
    },
    "events_count": 3
  },
  "status": "ok"
}

Removes the changes of the given dates, so they follow the weekly hours again.

HTTP Request

DELETE https://timelydo.com/api/v1/availability_schedules/<ID>/overrides

Body parameters

Parameter Description
dates A list of dates, YYYY-MM-DD.

Errors

Errors use the same response shape as successful requests:

{
  "success": false,
  "message": "missing params",
  "data": {
    "user": ["profile_picture"]
  },
  "status": 400
}

When a request fails, success is false, message says what went wrong, and status repeats the HTTP status. For some errors data carries extra detail, such as the list of missing fields.

HTTP status status field Meaning
400 400 Required parameters are missing. data lists them, grouped by object.
401 unauthorized The X-API-KEY header is missing, or the key is not valid. See Authentication.
403 forbidden Your key is valid, but this endpoint only works from a signed in browser session.
404 not_found The endpoint does not exist (endpoint does not exists), or the record you asked for was not found.
422 unprocessable_entity The request was understood, but the values failed validation. message explains which ones.
500 internal_server_error Something went wrong on our side. Try again later. If it keeps happening, contact support.