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.

REST · JSONPer-property keys100 req/min

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.

ScopeGrants
bookings:readList and read bookings, availability, quotes
bookings:writeCreate, change and cancel bookings; create blocks
property:readRead the property profile and its units
property:writeChange 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 as 400s.
  • Ids are opaque strings. Dates are YYYY-MM-DD; check_out and every to are exclusive (1–4 June is three nights). Timestamps are RFC 3339 in UTC.
  • Lists come as {"data": […], "page", "per_page", "total"}; per_page up to 200.
  • Errors come as {"error": {"code", "message", "details"}}. details names 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 as null.
  • Nothing is cached (Cache-Control: no-store). The version is in the path; fields are only ever added inside v1.

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

EndpointScopeWhat it does
GET/me—The key and its property
GET/propertyproperty:readProfile
PATCH/propertyproperty:writeChange name, currency, location
GET/units, /units/{id}property:readUnits with capacity, base price, breakfast
GET/availabilitybookings:readBusy ranges per unit in a window
GET/quotebookings:readPrice a stay
GET/bookings, /bookings/{id}bookings:readList with filters, read one
POST/bookingsbookings:writeCreate a booking or a block
PATCH/bookings/{id}bookings:writeChange any fields
DELETE/bookings/{id}bookings:writeCancel (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

CodeHTTPMeaning
missing_api_key401No credential sent
invalid_api_key401Unknown, revoked or expired key
insufficient_scope403The key lacks the scope
validation_error400A field is wrong — see details
invalid_json400Not JSON, empty, over 64 KB, or unknown fields
not_found404No such resource in this property
unit_unavailable409Another live booking overlaps
unit_blocked409A block overlaps
rate_limited429Over 100 requests in a minute; wait Retry-After
internal_error500Our 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_since rather than refetching everything.
  • Treat 409 as a normal outcome: re-read /availability and offer other dates.
  • Retry 429 after Retry-After and 5xx with backoff, always with an Idempotency-Key on 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.