Create a booking
POST/api/v1/bookings
bookings:writeIdempotentBooks a contact in. starts_at must be a time GET /bookings/slots currently offers for this type and member; the end time, title and location come from the type. If the time has gone — taken a moment ago, or no longer inside working hours — the answer is 409 conflict with reason: "slot_taken": load the slots again and let the customer choose another.
Say who it is for in one of two ways. contact_id books an existing contact. contact takes a name and phone, matches a contact that already has that number, and creates one if there is none — the right choice for a website form, where you do not know whether this person has written in before.
If the account has a booking message switched on in Bookings → Settings (a confirmation, a reminder, a cancellation notice), it is sent to the contact exactly as it would be for a booking made in the dashboard.
Call it from your own server, not from browser JavaScript, and send an Idempotency-Key header so a retry does not book the customer twice.
Body
application/json| Name | Type | Description |
|---|---|---|
type_idrequired | string (uuid) | The booking type |
assignee_user_idrequired | string (uuid) | The member to book, from GET /bookable-members |
starts_atrequired | string (date-time) | A start time from GET /bookings/slots |
contact_idoptional | string (uuid) | An existing contact. Send this or `contact` |
contactoptional | object | Find the contact by phone, or create it. Send this or `contact_id` |
contact.namerequired | string | The customer’s name |
contact.phonerequired | string | E.164 phone number, e.g. +971501234567 |
contact.emailoptional | string (email) | null | |
notesoptional | string | null | Free text for the team |
curl -X POST "https://your-crm.example.com/api/v1/bookings" \
-H "Authorization: Bearer $RAABTA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2a9e-3b4d-4c5e-8f7a-9b0c1d2e3f4a" \
-d '{
"type_id": "b00c1e00-1234-4abc-9def-000000000002",
"assignee_user_id": "2f3e4d5c-6b7a-4c8d-9e0f-1a2b3c4d5e6f",
"starts_at": "2026-09-05T10:00:00.000Z",
"contact": {
"name": "Ayesha Khan",
"phone": "+971501234567",
"email": "ayesha@example.com"
},
"notes": "Wants to discuss a website redesign"
}'{
"data": {
"id": "c0ffee00-1234-4abc-9def-000000000001",
"contact_id": "9d7e4c11-5a2b-4f7c-8e3d-2a1b0c9d8e7f",
"assignee_user_id": "2f3e4d5c-6b7a-4c8d-9e0f-1a2b3c4d5e6f",
"type": {
"id": "b00c1e00-1234-4abc-9def-000000000002",
"name": "Consultation"
},
"title": "Consultation",
"location": "Clinic, Room 2",
"notes": "Wants to discuss a website redesign",
"starts_at": "2026-09-05T10:00:00.000Z",
"ends_at": "2026-09-05T10:30:00.000Z",
"status": "scheduled",
"source": "api",
"cancel_reason": null,
"completed_at": null,
"created_at": "2026-09-02T10:05:00.000Z",
"updated_at": "2026-09-02T10:05:00.000Z"
}
}Response
Wrapped in { data: … }| Name | Type | Description |
|---|---|---|
idrequired | string (uuid) | UUID |
contact_idrequired | string (uuid) | null | The contact this booking is for |
assignee_user_idrequired | string (uuid) | null | The member who owns the slot |
typerequired | object | null | The booking type, when one is set |
type.idrequired | string (uuid) | UUID |
type.namerequired | string | |
titlerequired | string | null | Snapshotted from the type name at booking time |
locationrequired | string | null | Snapshotted from the type at booking time |
notesrequired | string | null | Free text recorded with the booking |
starts_atrequired | string (date-time) | ISO 8601 timestamp |
ends_atrequired | string (date-time) | ISO 8601 timestamp |
statusrequired | string (enum) | Where the booking is in its lifescheduledconfirmedcompletedno_showcancelled |
sourcerequired | string (enum) | Who booked it: staff in the dashboard, the AI assistant, the public booking page, or this APIstaffaipublicapi |
cancel_reasonrequired | string | null | What was recorded when it was cancelled |
completed_atrequired | string (date-time) | null | When it was marked completed |
created_atrequired | string (date-time) | ISO 8601 timestamp |
updated_atrequired | string (date-time) | ISO 8601 timestamp |
Errors
| Status | Code | When |
|---|---|---|
| 400 | bad_request | The request could not be parsed: malformed JSON, an invalid cursor, or a query parameter of the wrong shape. |
| 400 | validation_error | The body or query failed validation. `details` lists each failing field with a `path` and a `message`. |
| 401 | unauthorized | No usable API key: the Authorization header is missing or malformed, or the key is unknown, revoked or expired. The three are deliberately indistinguishable. |
| 402 | plan_limit_reached | The account has reached a plan limit for this resource. `details[0].limit` is the cap; ask the account owner to raise it. |
| 403 | forbidden | The key is valid but lacks the scope this endpoint requires, or the request came from an address outside the key’s IP allowlist. The message says which. |
| 403 | account_suspended | The account this key belongs to is suspended. Rotating the key will not help; contact support. |
| 409 | conflict | The request conflicts with current state: a duplicate phone number on create, an invalid state transition, or an idempotent request that is still in flight. |
| 422 | unprocessable | The request was well-formed but cannot be carried out. `reason` is a stable string saying why (for example `outside_window` or `stage_not_in_pipeline`). |
| 429 | rate_limited | The per-key budget, or the per-IP budget for failed authentication, is exhausted. Honour `Retry-After` before retrying. |
| 500 | internal | Something failed on our side. Safe to retry with the same Idempotency-Key; quote `request_id` if it persists. |