/ping
Check a key
Shows the business a key belongs to and what the key may do. Needs no scope: use it to test a key.
Answers: 200, 401, 429
A REST API with scoped keys for your members, schedule, bookings, check-ins, invoices and leads, and signed webhooks that tell your software the moment something happens. Nothing here moves money.
rd_live_ followed by 32 characters and is shown once.https://timetableos.com/api/public/v1.GET /ping, which shows the business and what the key may do.curl "$BASE/api/public/v1/members?status=active&per_page=50" \ -H "Authorization: Bearer $TIMETABLEOS_KEY"
require "net/http"
require "json"
uri = URI("#{ENV.fetch('BASE')}/api/public/v1/members?status=active")
request = Net::HTTP::Get.new(uri, "Authorization" => "Bearer #{ENV.fetch('TIMETABLEOS_KEY')}")
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
puts JSON.parse(response.body)["data"].map { |member| member["email"] }
const response = await fetch(`${process.env.BASE}/api/public/v1/members?status=active`, {
headers: { Authorization: `Bearer ${process.env.TIMETABLEOS_KEY}` },
});
const { data } = await response.json();
console.log(data.map((member) => member.email));
import os, requests
response = requests.get(
f"{os.environ['BASE']}/api/public/v1/members",
params={"status": "active"},
headers={"Authorization": f"Bearer {os.environ['TIMETABLEOS_KEY']}"},
)
print([member["email"] for member in response.json()["data"]])
Every request carries Authorization: Bearer <key>. A key belongs to one business and is never a person's login. Keep it on your server: anyone with the key can do what its scopes allow. We store only a fingerprint, so a lost key cannot be shown again. Make a new one, or rotate it, which gives you a new key and keeps the old one working for 24 hours.
A key can have an expiry and can be revoked at any time. Revoked, expired and unknown keys are refused with 401. Every call is logged against the key (path, status, time, address) and shown to the owner for 30 days.
A key may only do what its scopes say; anything else is a 403 insufficient_scope. A key can also be limited to some of the business's locations: it then sees only the members, sessions, invoices and products of those locations (and what belongs to the whole business), and a record outside them is a 404.
| Scope | Allows |
|---|---|
| members:read | List and read members |
| members:write | Create and update members |
| memberships:read | Read the memberships members hold |
| memberships:write | Sell a plan to a member (raises invoices, takes no payment) |
| plans:read | Read membership plans |
| classes:read | Read the schedule: classes and their sessions |
| bookings:read | Read bookings (reservations) |
| bookings:write | Book and cancel members into sessions |
| checkins:write | Record check-ins (door, kiosk or app) |
| invoices:read | Read membership invoices |
| leads:write | Create leads (enquiries) |
| products:read | Read shop products |
| webhooks:manage | Create, change and delete webhook endpoints |
Requests and responses are JSON. Money is an integer in the smallest unit (cents). Times are ISO 8601 in UTC; dates are YYYY-MM-DD. A resource carries an object field naming its type.
Lists take page and per_page (default 20, at most 100) and answer with the items, a total, and Link headers (first, prev, next, last). Each list documents its filters below; a filter that cannot be read is a 400, never ignored.
{
"object": "list",
"data": [{ "object": "member", "id": 1017, "first_name": "Ada" }],
"has_more": true,
"total": 132,
"page": 1,
"per_page": 20
}
Every GET answers with an ETag. Send it back as If-None-Match and an unchanged answer is a bodiless 304.
Every error has the same shape, and the HTTP status says the class: 400 a request that could not be read, 401 no valid key, 403 not allowed, 404 not found, 409 or 422 the request cannot be done, 429 slow down, 5xx our side. Branch on code, show message to people, and quote request_id when you write to us.
{
"error": {
"type": "validation_error",
"code": "validation_failed",
"message": "First name can't be blank",
"details": ["First name can't be blank"],
"request_id": "7c1d0f6e-8c1b-4a9e-9a43-0e5d5f1f2a11"
}
}
| Code | Status | Meaning |
|---|---|---|
| missing_api_key | 401 | No Authorization header. |
| invalid_api_key | 401 | The key does not exist. |
| api_key_revoked | 401 | The key was revoked. |
| api_key_expired | 401 | The key has expired. |
| insufficient_scope | 403 | The key lacks the scope the endpoint needs. |
| location_not_allowed | 403 | The key is limited to other locations. |
| business_suspended | 403 | The business is suspended. |
| resource_not_found | 404 | No such record in this business (or outside the key's locations). |
| unknown_endpoint | 404 | No such path. |
| invalid_parameter | 400 | A filter could not be read. |
| parameter_missing | 400 | A required parameter is missing. |
| invalid_json | 400 | The body is not JSON. |
| validation_failed | 422 | The request cannot be done; see details. |
| unknown_location | 422 | The location is not one of the business's. |
| idempotency_key_reused | 422 | The Idempotency-Key was used with a different request. |
| idempotency_in_progress | 409 | The first request with that key is still running. |
| rate_limited | 429 | Over the key's allowance. |
| internal_error | 500 | Our fault. Quote the request_id. |
Each key has its own allowance, 120 calls a minute unless the owner set another. Every answer says where you stand: X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix seconds when the minute ends). Past the limit you get a 429 with Retry-After in seconds. Requests without a valid key are counted per address at a much lower rate.
Send an Idempotency-Key header (any unique string, at most 255 characters) on a POST and a retry cannot do the work twice: the same key with the same body returns the first answer again, marked Idempotent-Replayed: true. The same key with a different body is a 422 idempotency_key_reused; a retry while the first request is still running is a 409. Keys are remembered for 24 hours and belong to the API key that sent them.
Base address: https://timetableos.com/api/public/v1. The same descriptions are in the OpenAPI 3.1 file.
/ping
Check a key
Shows the business a key belongs to and what the key may do. Needs no scope: use it to test a key.
Answers: 200, 401, 429
/members
List members
scope: members:read
Parameters
q string (query) Name, email, phone or member number.status string (query) One status.tag string (query) One tag.email string (query) Exact email.gym_id integer (query) Home location.updated_since string (query) Only members changed at or after this time (ISO 8601).page integer (query) Page number, from 1.per_page integer (query) Items per page, at most 100.Answers: 200, 401, 403, 429
/members
Create a member
scope: members:write
Parameters
Idempotency-Key string (header) Any unique string (at most 255 characters). A retry with the same key and body gets the first answer back (marked Idempotent-Replayed: true) and does the work once. The same key with a different body is a 422.Body (JSON)
first_name string, requiredlast_name stringemail stringphone_number stringdate_of_birth stringstatus lead | prospect | active | frozen | cancelled | formersource website_form | walk_in | referral | social | phone | otherhome_gym_id integertags array of stringAnswers: 201, 401, 403, 422, 429
/members/{id}
Get a member
scope: members:read
Parameters
id integer (path), requiredAnswers: 200, 401, 403, 404, 429
/members/{id}
Update a member
scope: members:write
Parameters
id integer (path), requiredBody (JSON)
first_name stringlast_name stringemail stringphone_number stringdate_of_birth stringstatus lead | prospect | active | frozen | cancelled | formersource stringhome_gym_id integertags array of stringAnswers: 200, 401, 403, 404, 422, 429
/members/{member_id}/memberships
Sell a plan to a member
scope: memberships:write
Uses the same rules as selling a plan at the front desk (eligibility, promo codes, signup fee). The membership starts pending with its invoices open: this API never takes a payment. The studio's billing collects it, or staff record it at the desk.
Parameters
member_id integer (path), requiredIdempotency-Key string (header) Any unique string (at most 255 characters). A retry with the same key and body gets the first answer back (marked Idempotent-Replayed: true) and does the work once. The same key with a different body is a 422.Body (JSON)
membership_plan_id integer, requiredgym_id integerstarted_on stringpromo_code stringAnswers: 201, 401, 403, 404, 422, 429
/memberships
List memberships
scope: memberships:read
Parameters
member_id integer (query) One member.status string (query) One status.plan_id integer (query) One plan.page integer (query) Page number, from 1.per_page integer (query) Items per page, at most 100.Answers: 200, 401, 403, 429
/memberships/{id}
Get a membership
scope: memberships:read
Parameters
id integer (path), requiredAnswers: 200, 401, 403, 404, 429
/plans
List plans
scope: plans:read
Parameters
status string (query) draft, active or archived. Default active.kind string (query) recurring, pass or pack.page integer (query) Page number, from 1.per_page integer (query) Items per page, at most 100.Answers: 200, 401, 403, 429
/plans/{id}
Get a plan
scope: plans:read
Parameters
id integer (path), requiredAnswers: 200, 401, 403, 404, 429
/classes
List classes
scope: classes:read
Parameters
status string (query) active or archived. Default active.kind string (query) class, appointment, workshop or open_access.page integer (query) Page number, from 1.per_page integer (query) Items per page, at most 100.Answers: 200, 401, 403, 429
/classes/{id}
Get a class
scope: classes:read
Parameters
id integer (path), requiredAnswers: 200, 401, 403, 404, 429
/occurrences
List sessions of the classes
scope: classes:read
Parameters
from string (query) Start of the window (ISO 8601). Default now.to string (query) End of the window, exclusive.class_id integer (query) One class.gym_id integer (query) One location.status string (query) scheduled, cancelled or completed.page integer (query) Page number, from 1.per_page integer (query) Items per page, at most 100.Answers: 200, 401, 403, 429
/occurrences/{id}
Get a session
scope: classes:read
Parameters
id integer (path), requiredAnswers: 200, 401, 403, 404, 429
/reservations
List bookings
scope: bookings:read
Parameters
occurrence_id integer (query) One session.member_id integer (query) One member.status string (query) One status.page integer (query) Page number, from 1.per_page integer (query) Items per page, at most 100.Answers: 200, 401, 403, 429
/reservations
Book a member into a session
scope: bookings:write
Same rules as the front desk: capacity and waitlist, flags that block booking, clashes, and the member's entitlement. A single visit that would need paying for is refused, because this API records no payments.
Parameters
Idempotency-Key string (header) Any unique string (at most 255 characters). A retry with the same key and body gets the first answer back (marked Idempotent-Replayed: true) and does the work once. The same key with a different body is a 422.Body (JSON)
occurrence_id integer, requiredmember_id integer, requiredAnswers: 201, 401, 403, 404, 422, 429
/reservations/{id}
Get a booking
scope: bookings:read
Parameters
id integer (path), requiredAnswers: 200, 401, 403, 404, 429
/reservations/{id}/cancel
Cancel a booking
scope: bookings:write
Parameters
id integer (path), requiredIdempotency-Key string (header) Any unique string (at most 255 characters). A retry with the same key and body gets the first answer back (marked Idempotent-Replayed: true) and does the work once. The same key with a different body is a 422.Body (JSON)
reason stringAnswers: 200, 401, 403, 404, 422, 429
/check-ins
Record a check-in
scope: checkins:write
Parameters
Idempotency-Key string (header) Any unique string (at most 255 characters). A retry with the same key and body gets the first answer back (marked Idempotent-Replayed: true) and does the work once. The same key with a different body is a 422.Body (JSON)
member_id integercode stringgym_id integersource desk | qr | kioskAnswers: 201, 401, 403, 404, 422, 429
/invoices
List invoices
scope: invoices:read
Parameters
status string (query) open, paid, failed, void or refunded.kind string (query) recurring, signup, ...membership_id integer (query) One membership.member_id integer (query) One member.due_from string (query) Due on or after.due_to string (query) Due on or before.page integer (query) Page number, from 1.per_page integer (query) Items per page, at most 100.Answers: 200, 401, 403, 429
/invoices/{id}
Get an invoice
scope: invoices:read
Parameters
id integer (path), requiredAnswers: 200, 401, 403, 404, 429
/leads
Capture a lead
scope: leads:write
The same capture the studio's own enquiry form uses.
Parameters
Idempotency-Key string (header) Any unique string (at most 255 characters). A retry with the same key and body gets the first answer back (marked Idempotent-Replayed: true) and does the work once. The same key with a different body is a 422.Body (JSON)
name stringfirst_name stringlast_name stringemail stringphone stringinterest stringmessage stringsource website_form | walk_in | referral | social | phone | othersource_detail stringgym_id integerconsented booleantags array of stringAnswers: 201, 401, 403, 422, 429
/products
List products
scope: products:read
Parameters
status string (query) active or archived. Default active.category string (query) One category.q string (query) Part of the name.page integer (query) Page number, from 1.per_page integer (query) Items per page, at most 100.Answers: 200, 401, 403, 429
/products/{id}
Get a product
scope: products:read
Parameters
id integer (path), requiredAnswers: 200, 401, 403, 404, 429
/webhook-endpoints
List endpoints
scope: webhooks:manage
Parameters
page integer (query) Page number, from 1.per_page integer (query) Items per page, at most 100.Answers: 200, 401, 403, 429
/webhook-endpoints
Add an endpoint
scope: webhooks:manage
Parameters
Idempotency-Key string (header) Any unique string (at most 255 characters). A retry with the same key and body gets the first answer back (marked Idempotent-Replayed: true) and does the work once. The same key with a different body is a 422.Body (JSON)
url string https in production. Private and loopback addresses are refused.description stringevents array of member.created | member.updated | membership.created | membership.frozen | membership.cancelled | payment.succeeded | payment.failed | reservation.booked | reservation.cancelled | check_in.recorded | lead.created | lead.converted | form.signed | feedback.submittedgym_ids array of integeractive booleanAnswers: 201, 401, 403, 422, 429
/webhook-endpoints/{id}
Get an endpoint
scope: webhooks:manage
Parameters
id integer (path), requiredAnswers: 200, 401, 403, 404, 429
/webhook-endpoints/{id}
Change an endpoint
scope: webhooks:manage
Parameters
id integer (path), requiredBody (JSON)
url string https in production. Private and loopback addresses are refused.description stringevents array of member.created | member.updated | membership.created | membership.frozen | membership.cancelled | payment.succeeded | payment.failed | reservation.booked | reservation.cancelled | check_in.recorded | lead.created | lead.converted | form.signed | feedback.submittedgym_ids array of integeractive booleanAnswers: 200, 401, 403, 404, 422, 429
/webhook-endpoints/{id}
Delete an endpoint
scope: webhooks:manage
Parameters
id integer (path), requiredAnswers: 204, 401, 403, 404, 429
/webhook-endpoints/{id}/test
Send a test event
scope: webhooks:manage
Parameters
id integer (path), requiredAnswers: 202, 401, 403, 404, 429
Add an endpoint under Developers → Webhooks (or with POST /webhook-endpoints), choose the events, and we POST a JSON envelope to it when they happen. The endpoint's signing secret is shown once.
{
"id": "evt_9f3a6c1e0b7d4a52c8e1f0a3",
"type": "member.created",
"api_version": "v1",
"created_at": "2026-10-05T09:30:12Z",
"business_id": 42,
"data": {
"object": {
"object": "member",
"id": 1017,
"first_name": "Ada",
"last_name": "Njoku",
"email": "[email protected]",
"status": "active"
}
}
}
id is the same on a retry or a manual resend; keep the ids you have handled and ignore repeats. Order is not guaranteed.POST /webhook-endpoints/:id/test) send a webhook.test event; resend any delivery from the log. Deliveries are kept for 30 days.api_version is v1. In v1 we only add fields; we never rename or remove one.Every delivery has an X-Signature header, t=<unix seconds>,v1=<hex>. Compute HMAC-SHA256, keyed with your endpoint's secret, of the string <t>.<raw request body>; it must equal v1 (compare in constant time). Use the raw bytes as received, not re-serialised JSON. Refuse a t more than five minutes from your clock: that stops a captured delivery being replayed. Other headers: X-Webhook-Id (the event id), X-Webhook-Event, X-Webhook-Delivery and X-Webhook-Attempt.
require "openssl"
# raw_body is the request body exactly as received, before any JSON parsing.
def valid_signature?(secret, raw_body, header, tolerance: 300)
parts = header.to_s.split(",").map { |part| part.split("=", 2) }.to_h
timestamp = parts["t"].to_i
return false if timestamp.zero? || (Time.now.to_i - timestamp).abs > tolerance
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
expected.bytesize == parts["v1"].to_s.bytesize && OpenSSL.fixed_length_secure_compare(expected, parts["v1"].to_s)
end
import crypto from "node:crypto";
// rawBody is a string or Buffer of the body exactly as received. With Express, use express.raw().
export function validSignature(secret, rawBody, header, toleranceSeconds = 300) {
const parts = Object.fromEntries(String(header ?? "").split(",").map((part) => part.split("=", 2)));
const timestamp = Number(parts.t);
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const given = Buffer.from(parts.v1 ?? "");
return given.length === expected.length && crypto.timingSafeEqual(Buffer.from(expected), given);
}
import hashlib, hmac, time
def valid_signature(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
parts = dict(part.split("=", 1) for part in (header or "").split(",") if "=" in part)
try:
timestamp = int(parts.get("t", ""))
except ValueError:
return False
if abs(time.time() - timestamp) > tolerance:
return False
signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
# Check a delivery you saved to a file (body.json) by hand. T and V come from the X-Signature header. T=1791230000 V=0c5a... # the v1 value printf '%s.%s' "$T" "$(cat body.json)" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex # The hex it prints must equal V.
Each event's data.object is the resource, in the same shape the API returns it.
member.createdA member, prospect or lead was added.Object: member. Fields: object, id, member_number, first_name, last_name, email, phone_number, date_of_birth, status, tags, source, home_gym_id, joined_at, created_at, and updated_at.
member.updatedA member's details or status changed.Object: member. Fields: object, id, member_number, first_name, last_name, email, phone_number, date_of_birth, status, tags, source, home_gym_id, joined_at, created_at, and updated_at.
membership.createdA plan was sold to a member.Object: membership. Fields: object, id, member_id, plan_id, plan_name, status, kind, price_cents, currency, billing_interval, billing_interval_count, started_on, ends_on, next_billing_on, cancel_on, cancelled_at, visits_remaining, gym_id, created_at, and updated_at.
membership.frozenA membership was frozen.Object: membership. Fields: object, id, member_id, plan_id, plan_name, status, kind, price_cents, currency, billing_interval, billing_interval_count, started_on, ends_on, next_billing_on, cancel_on, cancelled_at, visits_remaining, gym_id, created_at, and updated_at.
membership.cancelledA membership was cancelled.Object: membership. Fields: object, id, member_id, plan_id, plan_name, status, kind, price_cents, currency, billing_interval, billing_interval_count, started_on, ends_on, next_billing_on, cancel_on, cancelled_at, visits_remaining, gym_id, created_at, and updated_at.
payment.succeededAn invoice was paid.Object: invoice. Fields: object, id, number, membership_id, member_id, kind, status, description, amount_cents, subtotal_cents, tax_cents, discount_cents, currency, due_on, period_start, period_end, paid_at, attempt_count, failure_reason, gym_id, and created_at.
payment.failedAn attempt to collect an invoice failed.Object: invoice. Fields: object, id, number, membership_id, member_id, kind, status, description, amount_cents, subtotal_cents, tax_cents, discount_cents, currency, due_on, period_start, period_end, paid_at, attempt_count, failure_reason, gym_id, and created_at.
reservation.bookedA member was booked into a session (or its waitlist).Object: reservation. Fields: object, id, occurrence_id, member_id, membership_id, status, source, waitlisted_at, checked_in_at, cancelled_at, and created_at.
reservation.cancelledA booking was cancelled, early or late.Object: reservation. Fields: object, id, occurrence_id, member_id, membership_id, status, source, waitlisted_at, checked_in_at, cancelled_at, and created_at.
check_in.recordedA check-in was recorded, allowed or refused.Object: check_in. Fields: object, id, member_id, gym_id, membership_id, reservation_id, occurred_at, allowed, reason, source, and overridden.
lead.createdAn enquiry or walk-in was captured.Object: lead. Fields: object, id, member_id, first_name, last_name, email, phone_number, stage, stage_kind, source, source_detail, interest, tags, gym_id, converted_at, created_at, and duplicate.
lead.convertedA lead became a member.Object: lead. Fields: object, id, member_id, first_name, last_name, email, phone_number, stage, stage_kind, source, source_detail, interest, tags, gym_id, converted_at, created_at, and duplicate.
form.signedA member signed a waiver, contract or intake form.Object: form_signature. Fields: object, id, member_id, form_id, status, via, gym_id, signed_at, and expires_at.
feedback.submittedA member answered a post-visit survey.Object: feedback. Fields: object, id, member_id, nps, nps_category, rating, comment, gym_id, occurrence_id, and submitted_at.