Skip to content

Build on TimetableOS

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.

Quick startOpenAPI file

Quick start

  1. The studio's owner opens Business settings → Developers and makes an API key. The key looks like rd_live_ followed by 32 characters and is shown once.
  2. Send it as a bearer token to https://timetableos.com/api/public/v1.
  3. Check it works with GET /ping, which shows the business and what the key may do.
curl
curl "$BASE/api/public/v1/members?status=active&per_page=50" \
  -H "Authorization: Bearer $TIMETABLEOS_KEY"
Ruby
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"] }
Node
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));
Python
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"]])

Authentication

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.

Scopes and locations

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.

ScopeAllows
members:readList and read members
members:writeCreate and update members
memberships:readRead the memberships members hold
memberships:writeSell a plan to a member (raises invoices, takes no payment)
plans:readRead membership plans
classes:readRead the schedule: classes and their sessions
bookings:readRead bookings (reservations)
bookings:writeBook and cancel members into sessions
checkins:writeRecord check-ins (door, kiosk or app)
invoices:readRead membership invoices
leads:writeCreate leads (enquiries)
products:readRead shop products
webhooks:manageCreate, change and delete webhook endpoints

Pagination, filters, ETags

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.

Errors

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"
  }
}
CodeStatusMeaning
missing_api_key401No Authorization header.
invalid_api_key401The key does not exist.
api_key_revoked401The key was revoked.
api_key_expired401The key has expired.
insufficient_scope403The key lacks the scope the endpoint needs.
location_not_allowed403The key is limited to other locations.
business_suspended403The business is suspended.
resource_not_found404No such record in this business (or outside the key's locations).
unknown_endpoint404No such path.
invalid_parameter400A filter could not be read.
parameter_missing400A required parameter is missing.
invalid_json400The body is not JSON.
validation_failed422The request cannot be done; see details.
unknown_location422The location is not one of the business's.
idempotency_key_reused422The Idempotency-Key was used with a different request.
idempotency_in_progress409The first request with that key is still running.
rate_limited429Over the key's allowance.
internal_error500Our fault. Quote the request_id.

Rate limits

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.

Idempotency

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.

Endpoints

Base address: https://timetableos.com/api/public/v1. The same descriptions are in the OpenAPI 3.1 file.

Account

GET /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

GET /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

POST /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, required
  • last_name string
  • email string
  • phone_number string
  • date_of_birth string
  • status lead | prospect | active | frozen | cancelled | former
  • source website_form | walk_in | referral | social | phone | other
  • home_gym_id integer
  • tags array of string

Answers: 201, 401, 403, 422, 429

GET /members/{id} Get a member scope: members:read

Parameters

  • id integer (path), required

Answers: 200, 401, 403, 404, 429

PATCH /members/{id} Update a member scope: members:write

Parameters

  • id integer (path), required

Body (JSON)

  • first_name string
  • last_name string
  • email string
  • phone_number string
  • date_of_birth string
  • status lead | prospect | active | frozen | cancelled | former
  • source string
  • home_gym_id integer
  • tags array of string

Answers: 200, 401, 403, 404, 422, 429

Memberships

POST /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), required
  • 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)

  • membership_plan_id integer, required
  • gym_id integer
  • started_on string
  • promo_code string

Answers: 201, 401, 403, 404, 422, 429

GET /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

GET /memberships/{id} Get a membership scope: memberships:read

Parameters

  • id integer (path), required

Answers: 200, 401, 403, 404, 429

Plans

GET /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

GET /plans/{id} Get a plan scope: plans:read

Parameters

  • id integer (path), required

Answers: 200, 401, 403, 404, 429

Schedule

GET /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

GET /classes/{id} Get a class scope: classes:read

Parameters

  • id integer (path), required

Answers: 200, 401, 403, 404, 429

GET /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

GET /occurrences/{id} Get a session scope: classes:read

Parameters

  • id integer (path), required

Answers: 200, 401, 403, 404, 429

Bookings

GET /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

POST /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, required
  • member_id integer, required

Answers: 201, 401, 403, 404, 422, 429

GET /reservations/{id} Get a booking scope: bookings:read

Parameters

  • id integer (path), required

Answers: 200, 401, 403, 404, 429

POST /reservations/{id}/cancel Cancel a booking scope: bookings:write

Parameters

  • id integer (path), required
  • 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)

  • reason string

Answers: 200, 401, 403, 404, 422, 429

Check-ins

POST /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 integer
  • code string
  • gym_id integer
  • source desk | qr | kiosk

Answers: 201, 401, 403, 404, 422, 429

Invoices

GET /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

GET /invoices/{id} Get an invoice scope: invoices:read

Parameters

  • id integer (path), required

Answers: 200, 401, 403, 404, 429

Leads

POST /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 string
  • first_name string
  • last_name string
  • email string
  • phone string
  • interest string
  • message string
  • source website_form | walk_in | referral | social | phone | other
  • source_detail string
  • gym_id integer
  • consented boolean
  • tags array of string

Answers: 201, 401, 403, 422, 429

Shop

GET /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

GET /products/{id} Get a product scope: products:read

Parameters

  • id integer (path), required

Answers: 200, 401, 403, 404, 429

Webhook endpoints

GET /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

POST /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 string
  • events 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.submitted
  • gym_ids array of integer
  • active boolean

Answers: 201, 401, 403, 422, 429

GET /webhook-endpoints/{id} Get an endpoint scope: webhooks:manage

Parameters

  • id integer (path), required

Answers: 200, 401, 403, 404, 429

PATCH /webhook-endpoints/{id} Change an endpoint scope: webhooks:manage

Parameters

  • id integer (path), required

Body (JSON)

  • url string https in production. Private and loopback addresses are refused.
  • description string
  • events 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.submitted
  • gym_ids array of integer
  • active boolean

Answers: 200, 401, 403, 404, 422, 429

DELETE /webhook-endpoints/{id} Delete an endpoint scope: webhooks:manage

Parameters

  • id integer (path), required

Answers: 204, 401, 403, 404, 429

POST /webhook-endpoints/{id}/test Send a test event scope: webhooks:manage

Parameters

  • id integer (path), required

Answers: 202, 401, 403, 404, 429

Webhooks

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"
    }
  }
}
  • Answer with any 2xx within 5 seconds. Do the real work afterwards; answer first.
  • Retries. Anything else (a non-2xx, a timeout, a refused connection) is retried with growing waits: 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 8 hours, 8 hours. That is 8 tries in about a day. A 4xx other than 408, 409, 425 and 429 means "stop" and is not retried. Redirects are not followed.
  • Repeats. The event id is the same on a retry or a manual resend; keep the ids you have handled and ignore repeats. Order is not guaranteed.
  • Switched off. An endpoint whose last 5 deliveries all used up every try is switched off and the owner is told. Fix it, send a test, switch it back on.
  • Safety. In production the address must be https. Addresses that point at private, loopback or link-local networks are refused when you save them and again when we send.
  • Test and resend. From the screen (or POST /webhook-endpoints/:id/test) send a webhook.test event; resend any delivery from the log. Deliveries are kept for 30 days.
  • Versions. The envelope's api_version is v1. In v1 we only add fields; we never rename or remove one.

Verifying signatures

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.

Ruby
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
Node
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);
}
Python
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", ""))
curl
# 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.

Event catalogue

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.

Make a key and try it.

Start your studio