Developers

The Qouh API

Connect your CRM, website or accounting software: read appointment types, free times, bookings and customers, book and move visits at the times you offer, and get every change as a signed webhook.

Base URL
https://qouh.com/api

Download the OpenAPI fileThe reference below is in English, like the API itself.

Getting started

  1. Make a key under Setup → Integrations (on Starter, Pro and during the trial). Keys start with qh_ and are shown once.
  2. Test it: GET /v1/me answers with your organization’s name.
  3. Build on the reference below, or connect Zapier without writing code.
curl "https://qouh.com/api/v1/me" \
  -H "Authorization: Bearer $ROUTE_TRIM_KEY"

Keys and permissions

Send the key in the Authorization: Bearer header of every request, over https. A key can do only what you allowed when you made it:

  • bookings:read: See bookings and free times
  • bookings:write: Make, move and cancel bookings
  • customers:read: See customers
  • customers:write: Add and update customers
  • webhooks:manage: Set up webhooks (for Zapier)

Each key may make 120 requests a minute; beyond that the API answers 429 with a Retry-After header. A key you revoke on the Integrations page stops working at once.

Times and pages

Times are ISO 8601 in UTC. Lists come a page at a time: pass the answer’s nextCursor as cursor until it is null.

Errors

An error answers with an HTTP status and a body holding a stable code and a readable message:

{
  "error": {
    "code": "slot_unavailable",
    "message": "This time is not offered (any more). Pick one from GET /v1/slots."
  }
}
  • 400 The query or body is not valid (invalid_request).
  • 401 No key, or a key that is not valid or was revoked (unauthorized).
  • 403 The plan has no API (plan_required) or the key lacks the permission (insufficient_scope).
  • 404 Not found, or it belongs to another organization (not_found).
  • 409 The time is not offered (slot_unavailable), the booking is cancelled or has started (not_changeable), another customer has the email (email_taken), the webhook limit is reached (limit_reached), or a request with this Idempotency-Key is still running (idempotency_in_progress).
  • 422 The address could not be found (address_not_found), or the Idempotency-Key was used for another request (idempotency_key_reused).
  • 429 More than 120 requests a minute with this key (rate_limited); see Retry-After.
  • 503 Address lookup or routing is unavailable for a moment (unavailable).

Retrying safely

Writes take an optional Idempotency-Key header (a UUID, for example). The same key sent again within 24 hours gets the first answer back, marked Idempotent-Replayed: true, so a retry never books twice.

Webhooks

Add an endpoint under Setup → Integrations, or subscribe through the API, to hear about bookings and customers as they change. Each event is POSTed as JSON with these headers. A failed delivery is tried again for about a day; an endpoint that keeps failing for 3 days is turned off and the owners are emailed.

FieldTypeDescription
webhook-id
required
stringThe event’s id (msg_…), the same on every retry: use it to skip repeats.
webhook-timestamp
required
stringWhen this attempt was sent, in seconds since 1970 (UTC).
webhook-signature
required
stringSpace-separated v1,<base64> signatures: HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<body> keyed with the endpoint’s secret (the part after whsec_, base64-decoded). After “Roll secret” the old secret signs too for 24 hours.

Check the signature (Standard Webhooks) before trusting an event, refuse timestamps more than 5 minutes off, and use webhook-id to skip repeats. In Node.js:

import { createHmac, timingSafeEqual } from 'node:crypto'

// secret: the whsec_… shown when you added the endpoint
// rawBody: the request body exactly as received, before parsing
function verify(secret, headers, rawBody) {
  const id = headers['webhook-id']
  const timestamp = headers['webhook-timestamp']
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false
  const key = Buffer.from(secret.slice('whsec_'.length), 'base64')
  const expected = Buffer.from(
    createHmac('sha256', key).update(id + '.' + timestamp + '.' + rawBody).digest('base64'),
  )
  return headers['webhook-signature'].split(' ').some((part) => {
    const given = Buffer.from(part.slice('v1,'.length))
    return part.startsWith('v1,') && given.length === expected.length && timingSafeEqual(given, expected)
  })
}

Zapier

Our Zapier app is private for now: ask us for an invitation on the contact page. Connect it with a “Full access” key. Zaps can start when a booking is made, changed or cancelled or a customer is added, and can add or update a customer, find a customer and book the first free time.

Reference

Every request the API takes. Paths are relative to the base URL.

Your key

The key and its organization

get/v1/me

Any valid key may call this.

Any valid key may call this. Use it to test a connection.

Answer

200 with Me. Errors: 401, 403, 429.

Example
curl "https://qouh.com/api/v1/me" \
  -H "Authorization: Bearer $ROUTE_TRIM_KEY"

Appointment types

List appointment types

get/v1/appointment-types

Permission: bookings:read (See bookings and free times)

Every appointment type, active or not, sorted by name.

Query parameters
FieldTypeDescription
locale
optional
stringLanguage of appointment type names (default: the organization’s language).
One of:ennlfr
Answer

200 with AppointmentTypeList. Errors: 400, 401, 403, 429.

Example
curl "https://qouh.com/api/v1/appointment-types" \
  -H "Authorization: Bearer $ROUTE_TRIM_KEY"

Free times

Find bookable times

get/v1/slots

Permission: bookings:read (See bookings and free times)

The times an active appointment type can be booked at a place, as the booking page offers them: working hours, existing visits, busy time and the drive between visits are taken into account. Give lat and lng, or an address to look up in the organization's country. Sorted by time; rank 0 fits the route best.

Query parameters
FieldTypeDescription
type
required
integerThe appointment type’s id.
lat
optional
numberThe visit’s latitude (with lng).
lng
optional
numberThe visit’s longitude (with lat).
address
optional
stringThe visit’s address, looked up when lat and lng are not given.
from
optional
string (date-time)Start of the search (default: now).
to
optional
string (date-time)End of the search (default: a week after from; at most 31 days).
Answer

200 with SlotList. Errors: 400, 401, 403, 404, 422, 429, 503.

Example
curl "https://qouh.com/api/v1/slots?type=12&address=Grote%20Markt%201%2C%202000%20Antwerpen" \
  -H "Authorization: Bearer $ROUTE_TRIM_KEY"

Bookings

List bookings

get/v1/bookings

Permission: bookings:read (See bookings and free times)

Bookings sorted by start time, a page at a time. Follow nextCursor for the next page.

Query parameters
FieldTypeDescription
status
optional
stringOne status or a comma-separated list: pending, confirmed, completed, cancelled, no_show.
from
optional
string (date-time)Bookings starting at or after this time.
to
optional
string (date-time)Bookings starting before this time.
updatedSince
optional
string (date-time)Bookings changed at or after this time (to sync changes).
customer
optional
integerOnly this customer’s bookings.
locale
optional
stringLanguage of appointment type names (default: the organization’s language).
One of:ennlfr
limit
optional
integerItems per page, 1–100.
cursor
optional
stringThe nextCursor of the previous page.
Answer

200 with BookingList. Errors: 400, 401, 403, 429.

Example
curl "https://qouh.com/api/v1/bookings?status=confirmed&limit=20" \
  -H "Authorization: Bearer $ROUTE_TRIM_KEY"

Get a booking

get/v1/bookings/:id

Permission: bookings:read (See bookings and free times)

Path parameters
FieldTypeDescription
id
required
integerThe id.
Query parameters
FieldTypeDescription
locale
optional
stringLanguage of appointment type names (default: the organization’s language).
One of:ennlfr
Answer

200 with Booking. Errors: 400, 401, 403, 404, 429.

Example
curl "https://qouh.com/api/v1/bookings/123" \
  -H "Authorization: Bearer $ROUTE_TRIM_KEY"

Make a booking

post/v1/bookings

Permission: bookings:write (Make, move and cancel bookings)

Books a time from GET /v1/slots for the customer, as the booking page would: the engine picks who comes and checks the time is still offered. The customer is matched by email (or added) and gets the confirmation email.

JSON body
FieldTypeDescription
appointmentType
required
integerThe appointment type’s id.
start
required
string (date-time)The start, as a time from GET /v1/slots. ISO 8601 with an offset or Z.
customer
required
object
customer.name
required
string
customer.email
required
string (email)Customers are matched by email address (any case).
customer.phone
optional
stringStored with its country code (+32470123456) when it can be read as a number of the organization’s country or with a country code; otherwise as sent.
address
required
stringWhere the visit takes place.
location
optional
objectCoordinates (WGS 84). Without them the address is looked up.
location.lat
required
number
location.lng
required
number
locale
optional
stringThe language for the customer’s emails (default: the organization’s).
One of:ennlfr
Answer

201 with Booking. Errors: 400, 401, 403, 404, 409, 422, 429, 503.

Example
curl -X POST "https://qouh.com/api/v1/bookings" \
  -H "Authorization: Bearer $ROUTE_TRIM_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d @body.json

Cancel a booking

post/v1/bookings/:id/cancel

Permission: bookings:write (Make, move and cancel bookings)

Cancels a booking that has not started. The customer and the rep are told.

Path parameters
FieldTypeDescription
id
required
integerThe id.
Answer

200 with Booking. Errors: 400, 401, 403, 404, 409, 422, 429.

Example
curl -X POST "https://qouh.com/api/v1/bookings/123/cancel" \
  -H "Authorization: Bearer $ROUTE_TRIM_KEY" \
  -H "Idempotency-Key: $(uuidgen)"

Move a booking

post/v1/bookings/:id/reschedule

Permission: bookings:write (Make, move and cancel bookings)

Moves a booking that has not started to another time, same person and address, if that time is offered. The customer gets the new time and a new link to manage it.

Path parameters
FieldTypeDescription
id
required
integerThe id.
JSON body
FieldTypeDescription
start
required
string (date-time)The start, as a time from GET /v1/slots. ISO 8601 with an offset or Z.
Answer

200 with Booking. Errors: 400, 401, 403, 404, 409, 422, 429, 503.

Example
curl -X POST "https://qouh.com/api/v1/bookings/123/reschedule" \
  -H "Authorization: Bearer $ROUTE_TRIM_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d @body.json

Customers

List customers

get/v1/customers

Permission: customers:read (See customers)

Customers in the order they were added, a page at a time. Pass email to find one customer.

Query parameters
FieldTypeDescription
email
optional
stringFind the customer with this email address.
updatedSince
optional
string (date-time)Customers changed at or after this time (to sync changes).
limit
optional
integerItems per page, 1–100.
cursor
optional
stringThe nextCursor of the previous page.
Answer

200 with CustomerList. Errors: 400, 401, 403, 429.

Example
curl "https://qouh.com/api/v1/customers?email=jan%40example.com" \
  -H "Authorization: Bearer $ROUTE_TRIM_KEY"

Get a customer

get/v1/customers/:id

Permission: customers:read (See customers)

Path parameters
FieldTypeDescription
id
required
integerThe id.
Answer

200 with Customer. Errors: 401, 403, 404, 429.

Example
curl "https://qouh.com/api/v1/customers/123" \
  -H "Authorization: Bearer $ROUTE_TRIM_KEY"

Add or update a customer

post/v1/customers

Permission: customers:write (Add and update customers)

Adds the customer (201), or updates the one with this email (200).

JSON body
FieldTypeDescription
email
required
string (email)Customers are matched by email address (any case).
name
optional
string
phone
optional
string | nullStored with its country code (+32470123456) when it can be read as a number of the organization’s country or with a country code; otherwise as sent. Null clears it.
address
optional
stringThe customer’s address. Without location it is looked up in the background (location is null meanwhile).
location
optional
objectCoordinates (WGS 84). Without them the address is looked up.
location.lat
required
number
location.lng
required
number
locale
optional
stringThe language emails to this customer are sent in.
One of:ennlfr
Answer

200 / 201 with Customer. Errors: 400, 401, 403, 409, 422, 429.

Example
curl -X POST "https://qouh.com/api/v1/customers" \
  -H "Authorization: Bearer $ROUTE_TRIM_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d @body.json

Update a customer

patch/v1/customers/:id

Permission: customers:write (Add and update customers)

Path parameters
FieldTypeDescription
id
required
integerThe id.
JSON body
FieldTypeDescription
email
optional
string (email)Customers are matched by email address (any case).
name
optional
string
phone
optional
string | nullStored with its country code (+32470123456) when it can be read as a number of the organization’s country or with a country code; otherwise as sent. Null clears it.
address
optional
stringThe customer’s address. Without location it is looked up in the background (location is null meanwhile).
location
optional
objectCoordinates (WGS 84). Without them the address is looked up.
location.lat
required
number
location.lng
required
number
locale
optional
stringThe language emails to this customer are sent in.
One of:ennlfr
Answer

200 with Customer. Errors: 400, 401, 403, 404, 409, 422, 429.

Example
curl -X PATCH "https://qouh.com/api/v1/customers/123" \
  -H "Authorization: Bearer $ROUTE_TRIM_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d @body.json

Webhook subscriptions

Subscribe a webhook

post/v1/webhooks

Permission: webhooks:manage (Set up webhooks (for Zapier))

Adds a webhook endpoint for the given events (REST hooks, as Zapier uses them) and answers its signing secret once. An organization may have 50 endpoints added through the API (409 limit_reached beyond). An endpoint that answers 410 Gone is removed.

JSON body
FieldTypeDescription
url
required
stringWhere to POST the events: https, on a public address.
events
required
string[]The events to send there.
description
optional
stringShown on the Integrations page.
Answer

201 with WebhookSubscription. Errors: 400, 401, 403, 409, 422, 429.

Example
curl -X POST "https://qouh.com/api/v1/webhooks" \
  -H "Authorization: Bearer $ROUTE_TRIM_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d @body.json

Unsubscribe a webhook

delete/v1/webhooks/:id

Permission: webhooks:manage (Set up webhooks (for Zapier))

Removes an endpoint added through the API, with its delivery log. Endpoints added on the Integrations page are not found here.

Path parameters
FieldTypeDescription
id
required
integerThe id.
Answer

200 with WebhookDeleted. Errors: 400, 401, 403, 404, 409, 422, 429.

Example
curl -X DELETE "https://qouh.com/api/v1/webhooks/123" \
  -H "Authorization: Bearer $ROUTE_TRIM_KEY" \
  -H "Idempotency-Key: $(uuidgen)"

Objects

What the API answers with. A field that may be empty has null in its type.

Location

FieldTypeDescription
lat
required
number
lng
required
number

AppointmentType

FieldTypeDescription
id
required
integer
name
required
string
description
required
string | null
slug
required
string
durationMinutes
required
integer
bookingWindowDays
required
integerHow many days ahead customers may book.
minNoticeHours
required
integerThe shortest notice customers may book with.
active
required
booleanInactive types take no bookings and have no slots.
bookingUrl
required
string (uri)The public booking page for this type.

Customer

FieldTypeDescription
id
required
integer
name
required
string
email
required
string
phone
required
string | null
address
required
string | null
location
required
Location | nullNull while the address is not placed on the map.
locale
required
stringThe language emails to this customer are sent in.
One of:ennlfr
createdAt
required
string (date-time)When the customer was added. ISO 8601, in UTC.
updatedAt
required
string (date-time)When the customer last changed. ISO 8601, in UTC.

Booking

FieldTypeDescription
id
required
integer
status
required
string
One of:pendingconfirmedcompletedcancelledno_show
start
required
string (date-time)The start of the visit. ISO 8601, in UTC.
end
required
string (date-time)The end of the visit. ISO 8601, in UTC.
address
required
stringWhere the visit takes place.
location
required
Location
appointmentType
required
object
appointmentType.id
required
integer
appointmentType.name
required
string
rep
required
objectThe person who makes the visit.
rep.id
required
integer
rep.name
required
string | null
customer
required
Customer
source
required
stringWhere the booking came from.
One of:bookingPagemaintenanceInviteapicrm
locale
required
stringThe language the customer booked in.
One of:ennlfr
cancelledAt
required
string (date-time) | null
createdAt
required
string (date-time)When the booking was made. ISO 8601, in UTC.
updatedAt
required
string (date-time)When the booking last changed. ISO 8601, in UTC.

Slot

FieldTypeDescription
start
required
string (date-time)The start of the visit. ISO 8601, in UTC.
end
required
string (date-time)The end of the visit. ISO 8601, in UTC.
rank
required
integerHow well the time fits the route: 0 is the best fit.

BookingList

FieldTypeDescription
data
required
Booking[]
nextCursor
required
string | nullPass as cursor to get the next page; null on the last page.

CustomerList

FieldTypeDescription
data
required
Customer[]
nextCursor
required
string | nullPass as cursor to get the next page; null on the last page.

SlotList

FieldTypeDescription
timeZone
required
stringThe organization’s time zone (IANA), for showing the times.
data
required
Slot[]

Me

FieldTypeDescription
organization
required
object
organization.id
required
integer
organization.name
required
string
organization.slug
required
string
key
required
object
key.id
required
integer
key.label
required
string
key.prefix
required
string
key.scopes
required
string[]

Error

FieldTypeDescription
error
required
object
error.code
required
stringunauthorized, plan_required, insufficient_scope, rate_limited, not_found, invalid_request, address_not_found, slot_unavailable, not_changeable, email_taken, limit_reached, idempotency_key_reused, idempotency_in_progress, unavailable or internal_error.
error.message
required
string

BookingEvent

FieldTypeDescription
type
required
string
One of:booking.createdbooking.updatedbooking.cancelled
timestamp
required
string (date-time)When the event happened. ISO 8601, in UTC.
data
required
Booking
change
optional
stringOnly on booking.updated: rescheduled (a new time, by the customer or the API), moved (staff re-planned the day) or reassigned (another person makes the visit).
One of:rescheduledmovedreassigned

CustomerEvent

FieldTypeDescription
type
required
string
One of:customer.createdcustomer.updated
timestamp
required
string (date-time)When the event happened. ISO 8601, in UTC.
data
required
Customer

TestEvent

FieldTypeDescription
type
required
string
One of:webhook.test
timestamp
required
string (date-time)When the event happened. ISO 8601, in UTC.
data
required
object
data.endpointId
required
integer
data.message
required
string

WebhookSubscription

FieldTypeDescription
id
required
integer
url
required
string
events
required
string[]
description
required
string | null
status
required
string
One of:activedisabled
secret
required
stringThe signing secret (whsec_…) for webhook-signature. Only shown in this answer.
createdAt
required
string (date-time)When it was added. ISO 8601, in UTC.

WebhookDeleted

FieldTypeDescription
id
required
integer
deleted
required
boolean
One of:true

Webhook events

The events an endpoint can subscribe to, with the object each one carries.