Getting started
- Make a key under Setup → Integrations (on Starter, Pro and during the trial). Keys start with
qh_ and are shown once. - Test it:
GET /v1/me answers with your organization’s name. - 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 timesbookings:write: Make, move and cancel bookingscustomers:read: See customerscustomers:write: Add and update customerswebhooks: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.
| Field | Type | Description |
|---|
webhook-idrequired | string | The event’s id (msg_…), the same on every retry: use it to skip repeats. |
webhook-timestamprequired | string | When this attempt was sent, in seconds since 1970 (UTC). |
webhook-signaturerequired | string | Space-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
| Field | Type | Description |
|---|
localeoptional | string | Language of appointment type names (default: the organization’s language). One of:ennlfr |
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
| Field | Type | Description |
|---|
typerequired | integer | The appointment type’s id. |
latoptional | number | The visit’s latitude (with lng). |
lngoptional | number | The visit’s longitude (with lat). |
addressoptional | string | The visit’s address, looked up when lat and lng are not given. |
fromoptional | string (date-time) | Start of the search (default: now). |
tooptional | 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
| Field | Type | Description |
|---|
statusoptional | string | One status or a comma-separated list: pending, confirmed, completed, cancelled, no_show. |
fromoptional | string (date-time) | Bookings starting at or after this time. |
tooptional | string (date-time) | Bookings starting before this time. |
updatedSinceoptional | string (date-time) | Bookings changed at or after this time (to sync changes). |
customeroptional | integer | Only this customer’s bookings. |
localeoptional | string | Language of appointment type names (default: the organization’s language). One of:ennlfr |
limitoptional | integer | Items per page, 1–100. |
cursoroptional | string | The 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
| Field | Type | Description |
|---|
idrequired | integer | The id. |
Query parameters
| Field | Type | Description |
|---|
localeoptional | string | Language 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
| Field | Type | Description |
|---|
appointmentTyperequired | integer | The appointment type’s id. |
startrequired | string (date-time) | The start, as a time from GET /v1/slots. ISO 8601 with an offset or Z. |
customerrequired | object | |
customer.namerequired | string | |
customer.emailrequired | string (email) | Customers are matched by email address (any case). |
customer.phoneoptional | string | Stored 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. |
addressrequired | string | Where the visit takes place. |
locationoptional | object | Coordinates (WGS 84). Without them the address is looked up. |
location.latrequired | number | |
location.lngrequired | number | |
localeoptional | string | The 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
| Field | Type | Description |
|---|
idrequired | integer | The 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
| Field | Type | Description |
|---|
idrequired | integer | The id. |
JSON body
| Field | Type | Description |
|---|
startrequired | 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
| Field | Type | Description |
|---|
emailoptional | string | Find the customer with this email address. |
updatedSinceoptional | string (date-time) | Customers changed at or after this time (to sync changes). |
limitoptional | integer | Items per page, 1–100. |
cursoroptional | string | The nextCursor of the previous page. |
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
| Field | Type | Description |
|---|
idrequired | integer | The 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
| Field | Type | Description |
|---|
emailrequired | string (email) | Customers are matched by email address (any case). |
nameoptional | string | |
phoneoptional | string | null | Stored 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. |
addressoptional | string | The customer’s address. Without location it is looked up in the background (location is null meanwhile). |
locationoptional | object | Coordinates (WGS 84). Without them the address is looked up. |
location.latrequired | number | |
location.lngrequired | number | |
localeoptional | string | The 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
| Field | Type | Description |
|---|
idrequired | integer | The id. |
JSON body
| Field | Type | Description |
|---|
emailoptional | string (email) | Customers are matched by email address (any case). |
nameoptional | string | |
phoneoptional | string | null | Stored 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. |
addressoptional | string | The customer’s address. Without location it is looked up in the background (location is null meanwhile). |
locationoptional | object | Coordinates (WGS 84). Without them the address is looked up. |
location.latrequired | number | |
location.lngrequired | number | |
localeoptional | string | The 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
| Field | Type | Description |
|---|
urlrequired | string | Where to POST the events: https, on a public address. |
eventsrequired | string[] | The events to send there. |
descriptionoptional | string | Shown on the Integrations page. |
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
| Field | Type | Description |
|---|
idrequired | integer | The 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
| Field | Type | Description |
|---|
latrequired | number | |
lngrequired | number | |
AppointmentType
| Field | Type | Description |
|---|
idrequired | integer | |
namerequired | string | |
descriptionrequired | string | null | |
slugrequired | string | |
durationMinutesrequired | integer | |
bookingWindowDaysrequired | integer | How many days ahead customers may book. |
minNoticeHoursrequired | integer | The shortest notice customers may book with. |
activerequired | boolean | Inactive types take no bookings and have no slots. |
bookingUrlrequired | string (uri) | The public booking page for this type. |
Customer
| Field | Type | Description |
|---|
idrequired | integer | |
namerequired | string | |
emailrequired | string | |
phonerequired | string | null | |
addressrequired | string | null | |
locationrequired | Location | null | Null while the address is not placed on the map. |
localerequired | string | The language emails to this customer are sent in. One of:ennlfr |
createdAtrequired | string (date-time) | When the customer was added. ISO 8601, in UTC. |
updatedAtrequired | string (date-time) | When the customer last changed. ISO 8601, in UTC. |
Booking
| Field | Type | Description |
|---|
idrequired | integer | |
statusrequired | string | One of:pendingconfirmedcompletedcancelledno_show |
startrequired | string (date-time) | The start of the visit. ISO 8601, in UTC. |
endrequired | string (date-time) | The end of the visit. ISO 8601, in UTC. |
addressrequired | string | Where the visit takes place. |
locationrequired | Location | |
appointmentTyperequired | object | |
appointmentType.idrequired | integer | |
appointmentType.namerequired | string | |
reprequired | object | The person who makes the visit. |
rep.idrequired | integer | |
rep.namerequired | string | null | |
customerrequired | Customer | |
sourcerequired | string | Where the booking came from. One of:bookingPagemaintenanceInviteapicrm |
localerequired | string | The language the customer booked in. One of:ennlfr |
cancelledAtrequired | string (date-time) | null | |
createdAtrequired | string (date-time) | When the booking was made. ISO 8601, in UTC. |
updatedAtrequired | string (date-time) | When the booking last changed. ISO 8601, in UTC. |
Slot
| Field | Type | Description |
|---|
startrequired | string (date-time) | The start of the visit. ISO 8601, in UTC. |
endrequired | string (date-time) | The end of the visit. ISO 8601, in UTC. |
rankrequired | integer | How well the time fits the route: 0 is the best fit. |
BookingList
| Field | Type | Description |
|---|
datarequired | Booking[] | |
nextCursorrequired | string | null | Pass as cursor to get the next page; null on the last page. |
CustomerList
| Field | Type | Description |
|---|
datarequired | Customer[] | |
nextCursorrequired | string | null | Pass as cursor to get the next page; null on the last page. |
AppointmentTypeList
SlotList
| Field | Type | Description |
|---|
timeZonerequired | string | The organization’s time zone (IANA), for showing the times. |
datarequired | Slot[] | |
Me
| Field | Type | Description |
|---|
organizationrequired | object | |
organization.idrequired | integer | |
organization.namerequired | string | |
organization.slugrequired | string | |
keyrequired | object | |
key.idrequired | integer | |
key.labelrequired | string | |
key.prefixrequired | string | |
key.scopesrequired | string[] | |
Error
| Field | Type | Description |
|---|
errorrequired | object | |
error.coderequired | string | unauthorized, 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.messagerequired | string | |
BookingEvent
| Field | Type | Description |
|---|
typerequired | string | One of:booking.createdbooking.updatedbooking.cancelled |
timestamprequired | string (date-time) | When the event happened. ISO 8601, in UTC. |
datarequired | Booking | |
changeoptional | string | Only 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
| Field | Type | Description |
|---|
typerequired | string | One of:customer.createdcustomer.updated |
timestamprequired | string (date-time) | When the event happened. ISO 8601, in UTC. |
datarequired | Customer | |
TestEvent
| Field | Type | Description |
|---|
typerequired | string | One of:webhook.test |
timestamprequired | string (date-time) | When the event happened. ISO 8601, in UTC. |
datarequired | object | |
data.endpointIdrequired | integer | |
data.messagerequired | string | |
WebhookSubscription
| Field | Type | Description |
|---|
idrequired | integer | |
urlrequired | string | |
eventsrequired | string[] | |
descriptionrequired | string | null | |
statusrequired | string | One of:activedisabled |
secretrequired | string | The signing secret (whsec_…) for webhook-signature. Only shown in this answer. |
createdAtrequired | string (date-time) | When it was added. ISO 8601, in UTC. |
WebhookDeleted
| Field | Type | Description |
|---|
idrequired | integer | |
deletedrequired | boolean | One of:true |
Webhook events
The events an endpoint can subscribe to, with the object each one carries.