Skip to content

For developers

Developer guide

Connect your own website, app or accounting tool to Timzen. Read your services and open times, book and cancel appointments, and get a message the moment something changes.

Get started in 3 steps

The API is for a business that has a developer, or uses a tool that can talk to other tools. If that isn't you, you don't need any of this: your booking page and the Timzen app already do the job.

  1. Make a key. Sign in as the business owner, open Settings, then Developers, and choose Create key. Give it a name, and pick Read only (it can look but not change anything) or Read and write (it can also book and cancel appointments and add customers). The key is shown once. Copy it somewhere safe. You can have 10 active keys at a time and revoke any of them in one click.
  2. Send it with every request as a bearer token. A key belongs to one business and can only ever see that business.
  3. Try it. This lists your services:
Your first request
curl https://timzen.app/api/v1/services \
  -H "Authorization: Bearer tzk_your_key_here"

Prefer a machine-readable description? It's here: openapi.json (OpenAPI 3.1). Import it into Postman, Insomnia or a code generator.

The basics

  • Address: https://timzen.app/api/v1. Everything is JSON. There is one version, v1. We add new fields over time but never rename or remove one inside v1, so ignore fields you don't know.
  • Signing in: Authorization: Bearer tzk_…. Nothing else works. Being signed in to Timzen in a browser does not give any access to the API.
  • Times are ISO 8601 with the business's own UTC offset, for example 2026-10-21T10:00:00-04:00 for 10 AM in New York. They are exact moments, and they also read as the local clock time. Date filters like from=2026-10-21 mean midnight in the business's time zone.
  • Money is whole cents (6500 is $65.00) next to a currency code. Timzen doesn't take card payments through the API.
  • Ids are long random strings. Always use the ones the API gives you.
  • Lists come 25 at a time (ask for up to 100 with limit). When has_more is true, send next_cursor back as cursor to get the next page. Pages stay correct even while bookings are being added.
A list
{
  "object": "list",
  "data": [ { "object": "customer", "id": "…" } ],
  "has_more": true,
  "next_cursor": "WyIyMDI2LTEw…"
}

What you can do

Reading works with any key. The three actions marked “write” need a read and write key; with a read-only key they answer 403.

  • GET/services
    Your services: name, length, price, who can do them and where. Send limit and cursor to page through.
  • GET/team
    The team members customers can book, and which services and locations each one covers.
  • GET/locations
    Your locations, with time zone and address (a street you hide on the booking page is hidden here too).
  • GET/availability
    Open times for a service at a location. Needs location_id, service_id and from (a date); optional to (up to 31 days) and team_member_id (an id, or any). It uses exactly the same rules as your booking page: opening hours, time off, buffers, rooms and notice.
  • GET/appointments
    Appointments, earliest start first. Filter with from, to, status (one or more, comma-separated), team_member_id, location_id and customer_id.
  • GET/appointments/{id}
    One appointment with its services, team members, times and status.
  • POST/appointmentswrite
    Book an appointment. It goes through the same booking engine as your booking page, so every rule applies, and the customer gets the usual confirmation email. A time that is no longer free answers 409. If a service can happen in more than one place (at your business, at the customer's address, online), say which with mode; each service lists its modes.
  • POST/appointments/{id}/cancelwrite
    Cancel an appointment. Send an optional reason. The customer is told, exactly as if your team had cancelled.
  • GET/customers
    Your customers. Add email= to look one up.
  • GET/customers/{id}
    One customer.
  • POST/customerswrite
    Add a customer. If someone with that email already exists you get them back (200) instead of a duplicate (201 means a new one was made).

Appointments made through the API show as made by your team and are marked as coming from the API in your activity log, with the short start of the key (never the whole key).

Example: book an appointment

First ask for open times. Each slot lists the team members free at that moment.

Ask for open times
curl "https://timzen.app/api/v1/availability?location_id=LOCATION_ID&service_id=SERVICE_ID&from=2026-10-21&to=2026-10-23" \
  -H "Authorization: Bearer tzk_your_key_here"
The answer
{
  "object": "availability",
  "location_id": "6b1f…",
  "service_id": "c3a0…",
  "timezone": "America/New_York",
  "from": "2026-10-21",
  "to": "2026-10-23",
  "slots": [
    { "start": "2026-10-21T10:00:00-04:00", "date": "2026-10-21", "team_member_ids": ["9d2e…", "41b7…"] },
    { "start": "2026-10-21T10:30:00-04:00", "date": "2026-10-21", "team_member_ids": ["9d2e…"] }
  ],
  "next_available_date": null
}

Then book one. The Idempotency-Key makes it safe to send again if you never heard back: you get the first answer, not a second booking (see Limits below).

Book it
curl -X POST https://timzen.app/api/v1/appointments \
  -H "Authorization: Bearer tzk_your_read_write_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 8f14e45f-ceea-467a-9575-1f6f1d1b2c3d" \
  -d '{
    "location_id": "LOCATION_ID",
    "service_id": "SERVICE_ID",
    "team_member_id": "any",
    "start": "2026-10-21T10:00:00-04:00",
    "customer": { "first_name": "Sam", "last_name": "Rivera", "email": "sam@example.com" }
  }'

You get 201 and the appointment. Use "customer": { "id": … } instead to book someone already on your list.

Errors

Every error has the same shape. Use the HTTP status first, type second and code for the exact reason. param names the field at fault, and request_id is what to quote if you contact support.

An error
{
  "error": {
    "type": "conflict_error",
    "code": "slot_unavailable",
    "message": "That time was just taken. Please pick another.",
    "param": "start",
    "request_id": "req_Qm3x9TzK2pLw"
  }
}
  • 400 / 422 invalid_request_error: something in the request is wrong or missing. Fix it and try again.
  • 401 authentication_error: the key is missing, wrong or revoked, or the person who made it is no longer an owner.
  • 403 permission_error: a read-only key tried to change something, or the business is suspended.
  • 404 not_found_error: no such thing in your business.
  • 409 conflict_error: the world changed under you, for example the time was just taken.
  • 429 rate_limit_error: slow down; see below.
  • 500 api_error: our side. Try again, and quote the request id if it keeps happening.

Limits and safe retries

  • Speed: 120 requests per minute for each key (60 seconds, rolling). Every answer carries X-RateLimit-Limit and X-RateLimit-Remaining. Go over and you get 429 with a Retry-After header saying how many seconds to wait. Many wrong keys from one place (30 a minute) are slowed down the same way.
  • Size: a request body can be up to 64 KB.
  • Retrying a booking safely: add an Idempotency-Key header (a fresh UUID per booking) to POST requests. If the connection drops and you send the same request with the same key, you get the original answer back with Idempotent-Replayed: true and nothing is booked twice. We remember a key for 24 hours. Using one key for a different request is refused (422).
  • Two people, one slot: if two requests go for the same time, one wins and the other gets a clean 409 slot_unavailable. Never a double booking.

Webhooks

Instead of asking Timzen again and again, let it tell you. As the owner, open Settings → Developers → Webhooks, add a web address that starts with https://, and tick the events you want. You can have 5 addresses. We show you a signing secret (starting whsec_) once. Use Send test to try it before real bookings arrive.

  • appointment.created: A new appointment is made, by anyone: the booking page, your team, or the API.
  • appointment.updated: An appointment moves, changes team member or place, or its status changes (confirmed, checked in, completed, no-show).
  • appointment.cancelled: An appointment is cancelled by the customer, your team, the API, or because a request expired.
  • customer.created: A new customer record is created (a bulk import is not announced one by one).

Each event is a POST with a JSON body holding the same appointment or customer the API returns, and these headers:

Headers we send
Content-Type: application/json; charset=utf-8
User-Agent: Timzen-Webhooks/1
Timzen-Timestamp: 1792605730
Timzen-Signature: v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Timzen-Event-Id: 0b3c6f0e-5f4c-4a43-9a53-8f6e1d2b7a10
Timzen-Event-Type: appointment.cancelled
Timzen-Delivery-Id: 7d1c…
Timzen-Delivery-Attempt: 1
The body
{
  "id": "0b3c6f0e-5f4c-4a43-9a53-8f6e1d2b7a10",
  "object": "event",
  "type": "appointment.cancelled",
  "api_version": "v1",
  "created": "2026-10-20T16:42:10-04:00",
  "data": {
    "object": { "object": "appointment", "id": "…", "status": "cancelled", "…": "…" }
  }
}

What to do when one arrives

  • Answer with any 2xx, fast. We wait up to 10 seconds. Do the slow work afterwards. We don't follow redirects, and we only call real public addresses (nothing on a private or internal network).
  • If you don't answer 2xx we try again, up to 8 tries in all, waiting at least 30 seconds, 2 minutes, 10 minutes, 30 minutes, 2 hours, 6 hours, 12 hours between them (about a day in all). Our scheduler checks for due retries every 15 minutes, so a retry can come a little later than those gaps. Every try is signed fresh.
  • If an address keeps failing (15 failed tries in a row) we turn it off and tell the owner in the app, so we aren't knocking on a door that's closed. Fix the problem and turn it back on from the same page.
  • Expect repeats and the odd out-of-order message. The event id is the same on every retry, so remember the ones you've handled. The body is built when we send it, so a retry carries the latest version of the record. For appointments, compare updated_at if order matters.
  • See what happened. The Webhooks tab shows every delivery from the last 30 days: when, the answer your server gave and why a try failed. It never shows customer details.

Checking a webhook is really from us

Anyone could post to your address, so check the signature. We compute an HMAC-SHA256 of the timestamp, a dot, and the raw request body, using your signing secret, and send it as Timzen-Signature: v1=…. Check it before you parse the JSON, compare in constant time, and refuse anything with a timestamp more than 5 minutes from your clock (that stops someone replaying an old message).

Node.js
const { createHmac, timingSafeEqual } = require("node:crypto");

function verifyTimzen(rawBody, headers, secret) {
  const timestamp = headers["timzen-timestamp"];
  const signature = headers["timzen-signature"];
  if (!timestamp || !signature || !/^\d+$/.test(timestamp)) return false;

  // Refuse anything signed more than 5 minutes ago (or in the future).
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const expected = "v1=" + createHmac("sha256", secret)
    .update(timestamp + "." + rawBody)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && timingSafeEqual(a, b);
}
Python
import hashlib, hmac, time

def verify_timzen(raw_body: bytes, headers, secret: str) -> bool:
    timestamp = headers.get("Timzen-Timestamp", "")
    signature = headers.get("Timzen-Signature", "")
    if not timestamp.isdigit() or not signature:
        return False

    # Refuse anything signed more than 5 minutes ago (or in the future).
    if abs(time.time() - int(timestamp)) > 300:
        return False

    signed = timestamp.encode() + b"." + raw_body
    digest = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest("v1=" + digest, signature)

Lost the secret? Choose New secret on the endpoint. The old one stops working immediately.

Keeping it safe

  • A key can see and do whatever the owner could, within its access. Keep it out of web pages, app code that ships to customers, and public repositories. Use it from a server.
  • One key per program, with a name you'll recognise. The list shows when each was last used, so a key nobody uses can be revoked.
  • Revoke at once if a key might have leaked. The very next request with it is refused. Changes made with keys and webhooks are written to your activity log.
  • We store only a one-way fingerprint of your keys, so we can't show one again. Webhook secrets are stored encrypted and shown once.
  • Customer details are personal information. Only keep what you need, and see our Privacy Policy and Business Terms.

Stuck? Visit the Help centre. Include the request_id from the error.