Version 1 lets a tenant's own website read the room catalogue, check room-category availability, create a reservation (optionally paid in the same call), look a reservation up, cancel it, and record a payment already collected by the property. These endpoints do not initialize or verify a Paystack transaction.
| Method | Path | Purpose |
|---|---|---|
GET |
/rooms |
Room catalogue: categories, photos, capacity, base rate |
GET |
/availability |
Free units and dated price quotes |
POST |
/reservations |
Reserve one or more rooms, optionally with a payment |
GET |
/reservations/{reference} |
Look up a reservation |
POST |
/reservations/{reference}/cancel |
Cancel a reservation |
POST |
/reservations/{reference}/payments |
Record a payment already collected |
The guide and interactive token tester are served publicly from the tenant hostname at /api/docs. The tester makes a read-only availability request, does not create a reservation, does not save the token, and clears the value after the request. The OpenAPI 3.1 specification is available at GET /api/v1/openapi.yaml for tools such as Postman or Swagger UI.
Base URL and authentication
Use the tenant's Keverna hostname, for example:
https://your-property.keverna.com/api/v1
Send the tenant-issued API secret as a bearer token on every request:
Authorization: Bearer kv_...
Accept: application/json
Content-Type: application/json
Keep this secret on the website's server. Do not put it in browser JavaScript, a mobile app, or a public repository. A token is valid only on the tenant host that issued it. Revoking it immediately prevents further requests.
Tokens belong to one branch. A manager picks the branch when issuing the token (businesses with a single branch don't see the picker). Every call answers for that branch only: /rooms and /availability list its categories, POST /reservations accepts only its categories (404 category_not_found otherwise), rooms are assigned from its rooms, and lookup, payments and cancel find only its reservations (404 reservation_not_found otherwise). A business with several branch websites issues one token per branch. Tokens issued before branches were tied to tokens belong to the business's first (oldest) branch.
The property's public Keverna booking page does not need to be published: the API works for a hotel that only takes bookings on its own website.
Amounts are integers in the property's configured currency. Availability quotes are estimates; availability and the final price are checked again when a reservation is created.
Sandbox
New tokens start in sandbox, so a developer can build and test the whole flow against the property's real rooms and prices without touching its bookings. The same token and URLs work in both modes; a manager switches the token to live in Settings, then API tokens (and can move it back to sandbox at any time).
How to tell you are in sandbox:
- Every response to a sandbox token carries a
Keverna-Sandbox: trueheader, reads and idempotent replays included. Live responses don't have it. - Reservation, lookup, payment and cancel responses add
"sandbox": true; otherwise the shape is exactly the live one. - Sandbox references start with
TEST-(for exampleTEST-7Q3W9KPA); live references start withKV-.
What is real and what is simulated:
| Call | In sandbox |
|---|---|
GET /rooms, GET /availability |
Real data, read only. Same as live. |
POST /reservations |
Runs every live check (validation, pricing, minimum stay, capacity, availability, room assignment, payment rules), then is undone. Nothing is saved, no rooms are held, nobody is notified. |
GET /reservations/TEST-... |
Returns the simulated reservation, kept for 7 days. |
POST .../payments, POST .../cancel |
Work on TEST- reservations with the live rules: paid and balance update, repeat cancels are safe, unpaid ones expire after 15 minutes. |
Nothing from sandbox reaches the property's bookings, payments, availability, reports, dashboard or activity log; only the token's "last used" time is updated.
References don't cross modes: a TEST- reference sent with a live token, or a live reference sent with a sandbox token, returns 404 reservation_not_found with a detail saying which token to use. Each sandbox reservation belongs to the token that created it. Idempotency keys are kept separately per mode, so a key first used in sandbox starts fresh once the token is live.
Booking channel
Each token has a booking channel, chosen by the manager when issuing it (Own website by default; any front desk channel except Walk-in). Reservations made with the token are recorded under that channel and show under it in the property's reports, and the booking remembers which token created it. Changing a token's channel later only affects new reservations. Own website bookings carry no Keverna platform or Paystack fees.
Room catalogue
GET /rooms
Returns the property's active room categories. No dates are needed, so this is the call for building a rooms page. Use /availability for live counts and dated prices.
{
"property": { "name": "Your Property", "currency": "NGN", "amenities": ["Pool", "Wi-Fi"] },
"categories": [{
"id": 12,
"slug": "deluxe-room",
"name": "Deluxe Room",
"description": "Queen bed, city view and a work desk.",
"capacity_adults": 2,
"capacity_children": 1,
"min_nights": 1,
"base_rate": 50000,
"currency": "NGN",
"units": 4,
"photos": [{
"url": "https://your-property.keverna.com/storage/31/conversions/deluxe-full.jpg",
"medium": "https://your-property.keverna.com/storage/31/conversions/deluxe-medium.jpg",
"thumb": "https://your-property.keverna.com/storage/31/conversions/deluxe-thumb.jpg",
"is_hero": true
}]
}]
}
photoscome from the category gallery that the public booking pages use, hero image first.urlis up to 1920px,mediuman 800x600 crop andthumba 200x200 crop. Until a resized copy has been generated the original file is returned in its place. All URLs are absolute.unitscounts active rooms in the category.base_rateis the nightly rate before date-based pricing rules.- Amenities are stored for the whole property, not per category, so they are returned on
property. - Responses carry an
ETagandCache-Control: private, max-age=300. Send the ETag back inIf-None-Matchto get304 Not Modifiedwhen nothing changed.
Check availability
GET /availability
| Query parameter | Required | Meaning |
|---|---|---|
checkin |
yes | Arrival date, YYYY-MM-DD |
checkout |
yes | Departure date, YYYY-MM-DD |
adults |
no | Adults, defaults to 1 |
children |
no | Children, defaults to 0 |
Example:
curl 'https://your-property.keverna.com/api/v1/availability?checkin=2026-11-02&checkout=2026-11-05&adults=2&children=0' \
-H 'Authorization: Bearer kv_REDACTED' \
-H 'Accept: application/json'
The response contains the property currency, categories, available unit counts, and per-unit nightly/total quotes. available is false and available_units is zero when dates, guest capacity, minimum-stay rules, or inventory do not fit. Category slug is the stable identifier to pass when creating the reservation. Availability can change before the reservation is submitted.
{
"property": { "name": "Your Property", "currency": "NGN" },
"checkin": "2026-11-02",
"checkout": "2026-11-05",
"nights": 3,
"categories": [{
"id": 12,
"slug": "deluxe-room",
"name": "Deluxe Room",
"capacity_adults": 2,
"capacity_children": 1,
"min_nights": 1,
"available": true,
"available_units": 2,
"currency": "NGN",
"quote": {
"nights": 3,
"total": 150000,
"nightly": [
{ "date": "2026-11-02", "amount": 50000 },
{ "date": "2026-11-03", "amount": 50000 },
{ "date": "2026-11-04", "amount": 50000 }
]
}
}]
}
Create a reservation
POST /reservations
Every write requires a client-generated Idempotency-Key (8 to 128 characters). Reusing the same key with the same method, path, and body replays the original response for 24 hours. Reusing it for a different request returns 409 idempotency_key_reused.
Idempotency-Key: website-order-6e92f174
{
"category": "deluxe-room",
"checkin": "2026-11-02",
"checkout": "2026-11-05",
"rooms": 1,
"adults": 2,
"children": 0,
"guest": {
"name": "Ada Okafor",
"email": "ada@example.com",
"phone": "+2348012345678"
},
"special_requests": "Late arrival",
"website_reference": "WEB-2026-1024",
"payment": {
"amount": 150000,
"method": "card",
"payment_reference": "PSK-T-839204",
"paid_at": "2026-10-25T18:45:00+01:00"
}
}
| Field | Required | Rule |
|---|---|---|
category |
yes | Category slug (max 120) |
checkin, checkout |
yes | YYYY-MM-DD, check-in today or later, check-out after it |
rooms |
no | 1 to 50, default 1 |
adults |
yes | At least 1 and at most capacity_adults x rooms |
children |
no | At most capacity_children x rooms, default 0 |
guest.name, guest.phone |
yes | Name max 120; phone at least 8 digits, max 32 |
guest.email |
no | Valid email, max 180 |
special_requests |
no | Max 1000 |
website_reference |
no | Your own order ID, max 120. Returned on every read |
payment |
no | Book and pay in one call, see below |
payment.amount |
with payment |
Integer in the property currency, at least 1, at most the reservation total |
payment.method |
with payment |
cash, transfer, card, pos or other |
payment.payment_reference |
with payment |
Your receipt reference, max 100, unique per property |
payment.paid_at |
no | ISO 8601 time the money arrived; defaults to now |
Book and pay in one call (recommended)
When the website has already taken the money (its own card checkout, a confirmed transfer), send it as payment. The reservation and the receipt are written in one database transaction through the same ledger logic as the payments endpoint:
- paid in full:
statusandreservation.statusareconfirmed; - part paid:
on_hold, with the rest inreservation.balance; - if the payment is rejected (amount above the total, or a
payment_referencealready used for another reservation), nothing is created and the call returnspayment_not_recordedwith409or422.
{
"status": "confirmed",
"reservation": {
"reference": "KV-7Q3W9KPA",
"status": "confirmed",
"payment_status": "succeeded",
"category": "deluxe-room",
"room_assigned": true,
"room_number": "204",
"room_numbers": ["204"],
"checkin": "2026-11-02",
"checkout": "2026-11-05",
"nights": 3,
"rooms": 1,
"adults": 2,
"children": 0,
"guest": { "name": "Ada Okafor", "email": "ada@example.com", "phone": "+2348012345678" },
"currency": "NGN",
"total": 150000,
"paid": 150000,
"refunded": 0,
"balance": 0,
"website_reference": "WEB-2026-1024",
"created_at": "2026-10-25T18:46:02+01:00",
"cancelled_at": null,
"pending_expires_at": null
},
"payment": {
"amount": 150000,
"method": "card",
"payment_reference": "PSK-T-839204",
"paid": 150000,
"balance": 0
}
}
Two-step flow and the 15-minute window
Without payment, the reservation is created pending (top-level status is pending_payment) and holds the rooms. Keverna cancels unpaid pending reservations 15 minutes after they are created and releases the rooms; reservation.pending_expires_at tells you exactly when. Record the payment with POST /reservations/{reference}/payments before then. If the money may arrive later than that, collect it first and use book and pay instead.
Several rooms in one reservation
rooms holds that many units of one category under one reservation and one reference, the same way the dashboard's quick reserve does it. Availability is checked for all units at once (409 no_availability if fewer are free), total is priced for every room and night, and guest capacity scales with the count. Keverna assigns a physical room for each unit straight away (see Room assignment), listed in room_numbers. To book different categories, create one reservation per category.
Room assignment
Every website reservation gets a real room straight away: Keverna picks a room of the booked category that is free for every night (inspected, then clean, then the rest; never one marked out of order) and blocks it. The room shows as booked on the front desk's board and calendar, so it cannot be sold to a walk-in. room_assigned is true and the room is in room_number / room_numbers.
If no single room is free for the whole stay (for example one room is free on the first nights and another on the last), the reservation still succeeds: it holds a unit of the category, room_assigned is false, and it appears in the property's "Needs a room" list so the front desk assigns one before arrival. The front desk can always move a guest to another room; the lookup endpoint follows the move.
Look up a reservation
GET /reservations/{reference}
Returns {"reservation": {...}} in the shape above. room_number is the assigned room; it follows if the front desk moves the guest. paid is collected minus refunded and balance is total - paid. pending_expires_at is set only while the reservation is pending; cancelled_at only once cancelled.
Any reservation of the token's branch can be read by its reference, including ones made at the front desk or by phone: the token is the property's own credential and references are not guessable. References that belong to another branch or another property return 404 reservation_not_found. So do TEST- references with a live token and live references with a sandbox token, with a detail naming the token to use (see Sandbox).
Cancel a reservation
POST /reservations/{reference}/cancel (requires Idempotency-Key)
{ "reason": "Guest changed plans" }
reason is optional (max 500) and is saved on the reservation's audit trail. Cancelling runs the same action as the dashboard's cancel button: the held nights are released at once, the property's cancellation rule applies (less than 7 days before check-in, a deposit is forfeit), corporate wallet holds are returned, and the property is notified. No money is refunded by the API; amount_held is what the property still holds and refunds through its own process.
{
"status": "cancelled",
"reservation": { "reference": "KV-7Q3W9KPA", "status": "cancelled", "cancelled_at": "2026-10-26T09:12:40+01:00", "...": "..." },
"cancellation": {
"reason": "Guest changed plans",
"days_until_checkin": 7,
"deposit_forfeit": false,
"amount_held": 150000
}
}
Cancelling is safe to repeat: an already cancelled reservation returns 200 with cancellation: null. A reservation that is checked in, checked out or marked no-show returns 409 reservation_not_cancellable.
Record a payment already collected
POST /reservations/{reservation-reference}/payments
This endpoint records an actual receipt (cash, bank transfer, card, POS, or other). It does not charge a card, call Paystack, or independently verify that the funds arrived. Call it only after the business confirms collection.
{
"amount": 50000,
"method": "transfer",
"payment_reference": "BANK-TRF-839204",
"paid_at": "2026-10-25T18:45:00+01:00"
}
payment_reference is required and unique per tenant. It makes retries safe even when the website retries with a new idempotency key or the token has been rotated. The recorded amount cannot exceed the outstanding reservation total. Partial receipts put the reservation on hold and reduce its balance; full payment confirms it. The response includes the updated reservation status and balance.
{
"status": "ok",
"reservation": {
"reference": "KV-7Q3W9KPA",
"status": "on_hold",
"payment_status": "succeeded",
"total": 150000,
"balance_due": 100000,
"paid": 50000,
"balance": 100000
}
}
Credential provisioning
Managers can issue and revoke tokens from Settings, then API tokens on the tenant dashboard, pick each token's booking channel, and switch it between sandbox and live (every change is written to the activity log). A new token secret is shown once; Keverna stores only its SHA-256 digest. Keep it on the website server and never expose it to public browser code.
Errors
Business errors return JSON with an error code and, where useful, a detail. Field validation failures return 422 with Laravel's message and errors fields.
| Status | Codes |
|---|---|
| 400 | idempotency_key_required |
| 401 | unauthorized |
| 403 | account_read_only (subscription expired: reads still work, live writes are paused until it renews; sandbox still works), account_locked (every call refused until the property renews) |
| 404 | property_unavailable (the token's branch no longer exists, or the business has no branch set up yet; ask the property for a new token), category_not_found, reservation_not_found (not in the token's branch; cross-mode references add a detail) |
| 409 | no_availability, payment_not_recorded, reservation_not_cancellable, idempotency_key_reused, request_in_progress |
| 422 | message + errors, guest_capacity_exceeded, minimum_stay_not_met (with minimum_nights), invalid_reservation, payment_not_recorded |
| 500 | reservation_failed |
Do not assume a reservation was created when a request returns 500; retry with the same idempotency key.
Current scope
V1 supports properties with an active accommodation property and active room categories. It creates category-level holds so the website does not need to choose or expose a physical room number. The API is server-to-server; it is not intended for direct calls from a public browser.