Your software, your bookings.
The CocoBook API gives a channel manager, a booking widget, an accounting export or your own script access to one property's bookings, availability and prices. JSON in and out, API keys with scopes, safe retries, an OpenAPI description.
Quick start
In CocoBook open Settings → Developer and create a key for the property you want to connect. Pick the permissions it needs; the key is shown once. Then check that it works:
Request
curl https://api.cocobook.app/v1/me \
-H "Authorization: Bearer cb_live_3fK9aBxQ7mNp2vLw8rTz4hJd6kYs1eCgU5oWnR0i"
Response · 200
{
"key": {
"id": "a1c3e5f7-9b2d-4e6f-8a0c-2d4f6b8a0c1e",
"name": "Channel manager",
"prefix": "cb_live_3fK9aB",
"scopes": ["bookings:read", "bookings:write"],
"created_at": "2026-10-14T09:12:00Z",
"expires_at": "2027-10-14T09:12:00Z"
},
"property": {
"id": "7d2f8a1c-9e4b-4d6a-8c3f-1b5e7a9d2c60",
"name": "Sea House",
"currency": "EUR",
"location": {
"country": "BG",
"country_name": "Bulgaria",
"region": "Burgas",
"town": "Sozopol",
"label": "Sozopol, Bulgaria"
},
"image_url": "https://cocobook.app/static/uploads/properties/7d2f8a1c.jpg",
"created_at": "2026-03-02T15:40:11Z"
}
}
expires_at is present only when the key was given an expiry, image_url only when the property has a picture. The same property object comes back from GET /property.
Base URL: https://api.cocobook.app/v1. The machine-readable description is at /v1/openapi.json — load it into Postman, Insomnia or an SDK generator.
Authentication & scopes
Every request carries the key in the Authorization header as a bearer token (X-API-Key: cb_live_… works too). A key belongs to one property: there is no property parameter, and anything belonging to another property is simply 404. Only owners and admins can create and revoke keys; revocation takes effect immediately.
| Scope | Grants |
|---|---|
bookings:read | List and read bookings, availability, quotes |
bookings:write | Create, change and cancel bookings; create blocks |
property:read | Read the property profile and its units |
property:write | Change the property profile (name, currency, location) |
What you get when the credential is missing, wrong, or lacks a scope:
No key · 401
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="cocobook-api"
{"error": {"code": "missing_api_key", "message": "Send the API key as 'Authorization: Bearer cb_live_…'."}}
Unknown, revoked or expired key · 401
{"error": {"code": "invalid_api_key", "message": "The API key is not valid."}}
Key without the scope · 403
{"error": {"code": "insufficient_scope", "message": "This key does not have the 'bookings:write' scope."}}
The 401 is deliberately the same for a revoked key and one that never existed. GET /me needs no scope at all, so it is the right call for a "test connection" button.
Treat a key like a password: use it from your server, never from a web page or a mobile app, and give each integration its own key with the smallest scopes it needs.
Conventions
- JSON, UTF-8. Send
Content-Type: application/json. Unknown fields are rejected so typos surface as400s. - Ids are opaque strings. Dates are
YYYY-MM-DD;check_outand everytoare exclusive (1–4 June is three nights). Timestamps are RFC 3339 in UTC. - Lists come as
{"data": […], "page", "per_page", "total"};per_pageup to 200. - Errors come as
{"error": {"code", "message", "details"}}.detailsnames the offending fields. - Optional string fields (
guest.email,guest.phone,notes,image_url,expires_at) are left out of responses when empty rather than sent asnull. - Nothing is cached (
Cache-Control: no-store). The version is in the path; fields are only ever added insidev1.
Every authenticated response carries the rate-limit headers, whatever the status:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 99
X-RateLimit-Reset: 37
Endpoints
| Endpoint | Scope | What it does |
|---|---|---|
GET/me | — | The key and its property |
GET/property | property:read | Profile |
PATCH/property | property:write | Change name, currency, location |
GET/units, /units/{id} | property:read | Units with capacity, base price, breakfast |
GET/availability | bookings:read | Busy ranges per unit in a window |
GET/quote | bookings:read | Price a stay |
GET/bookings, /bookings/{id} | bookings:read | List with filters, read one |
POST/bookings | bookings:write | Create a booking or a block |
PATCH/bookings/{id} | bookings:write | Change any fields |
DELETE/bookings/{id} | bookings:write | Cancel (also POST …/cancel) |
Bookings
The examples below follow one booking through its life: created for a July stay, read, extended by a night, then cancelled. They all use the unit "Sea view" from Property & units.
Create
Request
curl -X POST https://api.cocobook.app/v1/bookings \
-H "Authorization: Bearer cb_live_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-8812" \
-d '{
"unit_id": "b0e562a3-7c1e-4f0a-9d2b-5e8f1a6c3d47",
"check_in": "2026-07-03",
"check_out": "2026-07-06",
"guest": {"name": "Ana Petrova", "email": "ana@example.com", "nationality": "BG"},
"source": "booking_com",
"breakfast_guests": 2,
"deposit": 100
}'
Required: unit_id, check_in, check_out, guest.name. status defaults to confirmed (or send pending; bookings the team accepts in the manager without a deposit come back as deposit_pending, which holds the dates like confirmed); source defaults to direct and is one of direct, airbnb, booking_com, agoda, referral. Stays are at most 365 nights. Omit price and the stay is priced from the property's seasons plus breakfast, exactly as /quote would. The answer is 201 with the booking:
Response · 201
{
"id": "e394b1d0-2a6f-4c8e-b7d3-91f0a5c2e468",
"code": "B-4762",
"unit": {"id": "b0e562a3-7c1e-4f0a-9d2b-5e8f1a6c3d47", "name": "Sea view"},
"guest": {"name": "Ana Petrova", "email": "ana@example.com", "nationality": "BG"},
"check_in": "2026-07-03",
"check_out": "2026-07-06",
"nights": 3,
"status": "confirmed",
"source": "booking_com",
"price": 330,
"deposit": 100,
"currency": "EUR",
"breakfast": {"guests": 2, "price": 30},
"created_at": "2026-10-14T09:14:02Z",
"updated_at": "2026-10-14T09:14:02Z"
}
price is the total for the stay with breakfast included (here 300 for the room plus 30 for breakfast); breakfast.price is that line on its own. code is the short reference staff see in CocoBook.
Send a field the API does not know, or malformed JSON, and the body is rejected as a whole:
Response · 400
{"error": {"code": "invalid_json", "message": "json: unknown field \"check_int\""}}
A well-formed body with wrong values names every offending field at once, so one round trip is enough to fix a form:
Response · 400
{
"error": {
"code": "validation_error",
"message": "One or more fields are invalid.",
"details": {
"check_out": "must be after check_in",
"guest.email": "must be a valid email address",
"source": "must be direct, airbnb, booking_com, agoda or referral"
}
}
}
The unit must be free for the whole stay. The manager UI lets staff create overlapping bookings and flags them as conflicts; the API refuses them instead: 409 unit_unavailable when another live booking overlaps, 409 unit_blocked when a block does. details.conflicting_booking_ids says which:
Response · 409
{
"error": {
"code": "unit_unavailable",
"message": "The unit already has a booking overlapping these dates.",
"details": {"conflicting_booking_ids": ["0b1c7d9e-5f3a-4b2c-8e6d-7a9f1c3e5b20"]}
}
}
Safe retries
Send an Idempotency-Key header (any string unique to the attempt, up to 255 characters) and a retry after a timeout returns the booking created the first time instead of a duplicate. Keys are remembered for 24 hours per API key. The replay is a 200, not a 201, and is marked with a header:
Same request again · 200
HTTP/1.1 200 OK
Idempotent-Replayed: true
{"id": "e394b1d0-2a6f-4c8e-b7d3-91f0a5c2e468", "code": "B-4762", …}
Only the key is compared, not the body, so never reuse a key for a different order.
Blocks
Set status to cleaning, owner or blocked to close dates without a guest. guest.name is then optional and the block is never priced, whatever you send:
Request
POST /v1/bookings
{"unit_id": "b0e562a3-7c1e-4f0a-9d2b-5e8f1a6c3d47", "check_in": "2026-07-06", "check_out": "2026-07-07", "status": "cleaning"}
Response · 201
{
"id": "c58a2f61-0d4e-4b9a-a3c7-6e1f8b2d9a05",
"code": "BL-2210",
"unit": {"id": "b0e562a3-7c1e-4f0a-9d2b-5e8f1a6c3d47", "name": "Sea view"},
"guest": {},
"check_in": "2026-07-06",
"check_out": "2026-07-07",
"nights": 1,
"status": "cleaning",
"source": "direct",
"price": 0,
"deposit": 0,
"currency": "EUR",
"breakfast": {"guests": 0, "price": 0},
"created_at": "2026-10-14T09:15:30Z",
"updated_at": "2026-10-14T09:15:30Z"
}
Blocks get a BL- code and an empty guest. Your team sees every API write in CocoBook's notifications, named after the key.
Read one
Request
curl https://api.cocobook.app/v1/bookings/e394b1d0-2a6f-4c8e-b7d3-91f0a5c2e468 \
-H "Authorization: Bearer cb_live_…"
Answers 200 with the same booking object as create. An id from another property, a typo, or a deleted row all give the same answer:
Response · 404
{"error": {"code": "not_found", "message": "No booking with that id."}}
List and sync
GET /v1/bookings?from=2026-07-01&to=2026-08-01 # bookings overlapping July
GET /v1/bookings?status=confirmed,pending&unit_id=b0e5…
GET /v1/bookings?updated_since=2026-10-14T09:00:00Z # poll this for changes
GET /v1/bookings?include_blocks=true&page=2&per_page=100
Response · 200
{
"data": [
{
"id": "e394b1d0-2a6f-4c8e-b7d3-91f0a5c2e468",
"code": "B-4762",
"unit": {"id": "b0e562a3-7c1e-4f0a-9d2b-5e8f1a6c3d47", "name": "Sea view"},
"guest": {"name": "Ana Petrova", "email": "ana@example.com", "nationality": "BG"},
"check_in": "2026-07-03", "check_out": "2026-07-06", "nights": 3,
"status": "confirmed", "source": "booking_com",
"price": 330, "deposit": 100, "currency": "EUR",
"breakfast": {"guests": 2, "price": 30},
"created_at": "2026-10-14T09:14:02Z", "updated_at": "2026-10-14T09:14:02Z"
},
{
"id": "f7a0c3e2-6b1d-4a8f-9c5e-3d2b7f1a8e94",
"code": "B-1183",
"unit": {"id": "4f7a9c21-3b8d-4e6a-a1c5-2d9e8b7f6a10", "name": "Garden studio"},
"guest": {"name": "Tom Weber", "phone": "+49 170 1234567", "nationality": "DE"},
"check_in": "2026-07-10", "check_out": "2026-07-17", "nights": 7,
"status": "pending", "source": "direct",
"price": 560, "deposit": 0, "currency": "EUR",
"breakfast": {"guests": 0, "price": 0},
"notes": "Arrives after 22:00",
"created_at": "2026-10-02T18:03:44Z", "updated_at": "2026-10-02T18:03:44Z"
}
],
"page": 1,
"per_page": 50,
"total": 2
}
Sorted by check-in. total counts every match across pages; page starts at 1 and per_page defaults to 50. from and to select bookings that overlap the window, not only those starting in it. Blocks are left out unless include_blocks=true or status names them. Cancelled bookings stay listable with status: cancelled.
A booking staff created on top of another one in CocoBook comes back with status: conflict; you can filter on it, but the API never creates one.
For a sync, remember the time you last polled and ask for everything touched since; a cancel or an edit in CocoBook bumps updated_at so it shows up:
GET /v1/bookings?updated_since=2026-10-14T09:00:00Z&include_blocks=true&per_page=200
Change
PATCH takes the same fields as create; only those sent change. Moving the dates or the unit re-checks overlaps (the booking's own dates never count) and, unless a price is sent, re-prices the stay. Extending Ana's stay by a night:
Request
curl -X PATCH https://api.cocobook.app/v1/bookings/e394b1d0-2a6f-4c8e-b7d3-91f0a5c2e468 \
-H "Authorization: Bearer cb_live_…" \
-H "Content-Type: application/json" \
-d '{"check_out": "2026-07-07", "notes": "late arrival"}'
Response · 200
{
"id": "e394b1d0-2a6f-4c8e-b7d3-91f0a5c2e468",
"code": "B-4762",
"unit": {"id": "b0e562a3-7c1e-4f0a-9d2b-5e8f1a6c3d47", "name": "Sea view"},
"guest": {"name": "Ana Petrova", "email": "ana@example.com", "nationality": "BG"},
"check_in": "2026-07-03",
"check_out": "2026-07-07",
"nights": 4,
"status": "confirmed",
"source": "booking_com",
"price": 430,
"deposit": 100,
"currency": "EUR",
"breakfast": {"guests": 2, "price": 40},
"notes": "late arrival",
"created_at": "2026-10-14T09:14:02Z",
"updated_at": "2026-10-14T11:02:19Z"
}
If the cleaning block from above still sat on 6–7 July this would have answered 409 unit_blocked. Send "" to clear guest.email, guest.phone or notes; to keep the price you negotiated while moving dates, send it along:
Request
PATCH /v1/bookings/{id}
{"check_in": "2026-07-04", "check_out": "2026-07-08", "price": 400, "guest": {"email": ""}}
Cancel
Request
curl -X DELETE https://api.cocobook.app/v1/bookings/e394b1d0-2a6f-4c8e-b7d3-91f0a5c2e468 \
-H "Authorization: Bearer cb_live_…"
Response · 200
{
"id": "e394b1d0-2a6f-4c8e-b7d3-91f0a5c2e468",
"code": "B-4762",
"status": "cancelled",
"check_in": "2026-07-03",
"check_out": "2026-07-07",
"price": 430,
"updated_at": "2026-10-15T08:30:00Z",
…
}
POST /v1/bookings/{id}/cancel and PATCH with {"status": "cancelled"} do the same. Cancelling keeps the booking with status: cancelled and frees its dates and parking; cancelling twice is fine and answers 200 again.
Availability & quotes
Availability
Request
curl "https://api.cocobook.app/v1/availability?from=2026-07-01&to=2026-07-31&unit_id=b0e562a3-7c1e-4f0a-9d2b-5e8f1a6c3d47" \
-H "Authorization: Bearer cb_live_…"
Response · 200
{
"from": "2026-07-01",
"to": "2026-07-31",
"units": [
{
"unit": {"id": "b0e562a3-7c1e-4f0a-9d2b-5e8f1a6c3d47", "name": "Sea view"},
"busy": [
{"from": "2026-07-03", "to": "2026-07-06", "type": "booking", "status": "confirmed", "booking_id": "e394b1d0-2a6f-4c8e-b7d3-91f0a5c2e468"},
{"from": "2026-07-06", "to": "2026-07-07", "type": "block", "status": "cleaning"},
{"from": "2026-07-10", "to": "2026-07-14", "type": "external", "summary": "Airbnb (Not available)"}
]
}
]
}
Everything that is not free inside the window, per unit: live bookings (type: booking, with the id so you can read it), blocks, and external events imported from the iCal feeds the owner connected (Airbnb, Booking.com). Leave unit_id out to get every unit; a unit with nothing on has "busy": []. Windows of up to 366 days. Anything not listed is free, including cancelled bookings.
Both dates are required and the window must be at least one day:
Response · 400
{"error": {"code": "validation_error", "message": "One or more fields are invalid.", "details": {"to": "range must be at most 366 days"}}}
Quote
Request
curl "https://api.cocobook.app/v1/quote?unit_id=b0e562a3-7c1e-4f0a-9d2b-5e8f1a6c3d47&check_in=2026-07-03&check_out=2026-07-06&breakfast_guests=2" \
-H "Authorization: Bearer cb_live_…"
Response · 200
{
"unit": {"id": "b0e562a3-7c1e-4f0a-9d2b-5e8f1a6c3d47", "name": "Sea view"},
"check_in": "2026-07-03",
"check_out": "2026-07-06",
"nights": 3,
"currency": "EUR",
"room": 300,
"breakfast": {"guests": 2, "price": 30},
"total": 330,
"per_night": [
{"date": "2026-07-03T00:00:00Z", "rate": 90, "season": "Summer", "weekend": false},
{"date": "2026-07-04T00:00:00Z", "rate": 105, "season": "Summer", "weekend": true},
{"date": "2026-07-05T00:00:00Z", "rate": 105, "season": "Summer", "weekend": true}
]
}
Per night: the season's per-unit rate if set (its weekend variant on the property's weekend days), else the unit's base or weekend price times the season's multiplier, else the base or weekend price alone. season is absent on nights outside any season. Breakfast is guests × nights × the unit's per-person rate; a unit without breakfast always quotes {"guests": 0, "price": 0}. total is room plus breakfast.price, and a booking created without a price gets exactly this total.
A quote does not reserve anything and does not check availability. The usual flow for a booking widget is /availability to show free dates, /quote to show the price, then POST /bookings and treat a 409 as "someone was faster".
Property & units
Property
Request
curl -X PATCH https://api.cocobook.app/v1/property \
-H "Authorization: Bearer cb_live_…" \
-H "Content-Type: application/json" \
-d '{"name": "Sea House", "currency": "EUR", "location": {"country": "BG", "region": "Burgas", "town": "Sozopol"}}'
Response · 200
{
"id": "7d2f8a1c-9e4b-4d6a-8c3f-1b5e7a9d2c60",
"name": "Sea House",
"currency": "EUR",
"location": {
"country": "BG",
"country_name": "Bulgaria",
"region": "Burgas",
"town": "Sozopol",
"label": "Sozopol, Bulgaria"
},
"image_url": "https://cocobook.app/static/uploads/properties/7d2f8a1c.jpg",
"created_at": "2026-03-02T15:40:11Z"
}
Only the fields sent change; inside location too, so {"location": {"town": "Chernomorets"}} keeps the country and region. GET /v1/property returns the same object. Currencies: EUR USD GBP TRY BGN RON CHF; country is an ISO 3166-1 alpha-2 code; country_name and label are derived and read-only. A profile change refreshes the property's guest site and its Explore stays listing at once. The picture is not editable over the API.
Response · 400
{"error": {"code": "validation_error", "message": "One or more fields are invalid.", "details": {"currency": "must be one of EUR, USD, GBP, TRY, BGN, RON, CHF", "location.country": "must be an ISO 3166-1 alpha-2 code"}}}
Units
Request
curl https://api.cocobook.app/v1/units \
-H "Authorization: Bearer cb_live_…"
Response · 200
{
"data": [
{
"id": "b0e562a3-7c1e-4f0a-9d2b-5e8f1a6c3d47",
"name": "Sea view",
"type": "apartment",
"capacity": 2,
"floor": "2",
"base_price": 75,
"weekend_price": 90,
"breakfast": true,
"breakfast_price": 5
},
{
"id": "4f7a9c21-3b8d-4e6a-a1c5-2d9e8b7f6a10",
"name": "Garden studio",
"type": "studio",
"capacity": 3,
"floor": "",
"base_price": 80,
"weekend_price": 0,
"breakfast": false,
"breakfast_price": 0
}
]
}
Units are read-only over the API and the list is not paged. base_price is the weekday rate outside seasons and weekend_price the rate on the property's weekend days, 0 meaning the same as base_price. breakfast_price is per person per night, 0 meaning included. Seasons change what a night actually costs, so use /quote for a real price. GET /v1/units/{id} returns one unit object on its own; type is one of studio, apartment, villa, room, loft.
Errors & limits
| Code | HTTP | Meaning |
|---|---|---|
missing_api_key | 401 | No credential sent |
invalid_api_key | 401 | Unknown, revoked or expired key |
insufficient_scope | 403 | The key lacks the scope |
validation_error | 400 | A field is wrong — see details |
invalid_json | 400 | Not JSON, empty, over 64 KB, or unknown fields |
not_found | 404 | No such resource in this property |
unit_unavailable | 409 | Another live booking overlaps |
unit_blocked | 409 | A block overlaps |
rate_limited | 429 | Over 100 requests in a minute; wait Retry-After |
internal_error | 500 | Our fault; retry later |
details is present on validation_error (one message per field, nested fields as guest.email or location.country, query parameters by their name) and on the two 409s (conflicting_booking_ids). Every other error is just code and message.
Rate limit
Each key may make 100 requests per minute. The window is the clock minute, shared by all our servers, and it resets at the top of the next minute rather than 60 seconds after your first call. Every authenticated response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds until the window resets); only requests with a valid key count. Request 101 in a window gets:
Response · 429
HTTP/1.1 429 Too Many Requests
Retry-After: 23
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 23
{"error": {"code": "rate_limited", "message": "Too many requests; at most 100 per minute per key."}}
Pace a bulk import by watching X-RateLimit-Remaining, and sleep for Retry-After seconds on a 429 rather than retrying at once.
Good practice
- One key per integration, named after it, with only the scopes it needs. Revoke keys you stop using; give short-lived keys an expiry.
- Poll
updated_sincerather than refetching everything. - Treat
409as a normal outcome: re-read/availabilityand offer other dates. - Retry
429afterRetry-Afterand5xxwith backoff, always with anIdempotency-Keyon creates. - Read only the fields you need and tolerate new ones: responses gain fields inside
v1, never lose them.
Questions or a use case the API does not cover yet? Write to hello@cocobook.app.