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:
- Sign in at timelydo.com.
- Open Settings, then Developers Console, then API Key. You can also go straight to timelydo.com/settings/developers/api_key.
- 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
- Open Settings, then Developers Console, then API Key.
- Click Regenerate key.
- 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. |
| 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
- PNG, JPEG, GIF or WEBP. SVG is not accepted.
- 5 MB or smaller.
- The type is read from the file's content type, so set it correctly (for example
image/png).
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:
- Amounts are whole numbers in the smallest unit of the currency:
4900is $49.00 inusdandeur, and5000is 5,000 yen injpy, which has no cents. Never send decimals. - Each currency has a limit. The least is Stripe's own minimum for that currency (for example $0.50 in
usd), and the most is about the equivalent of US$10,000. Outside it you get422with a message such asPrice must be between $0.50 and $10,000.00. - 24 currencies are supported. Any other code is refused with
Currency is not a currency we can charge in. - A new price only applies to new bookings. People already in the middle of paying pay what they were shown.
- Paying happens on the booking page. The page takes the payment first, and the meeting is booked only once it has gone through. The booking then appears in List bookings for an event like any other.
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
Single use links
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 |
|---|---|
| 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. |