Integration guide
Integrating with OGIVS
How an organization mints credentials for machine access, registers the signing secrets outbound deliveries are signed with, and takes a guest from a photographed document to a registered, verified record.
Overview
OGIVS issues two distinct kinds of credential, and it is worth being clear about which direction each one points:
API keys
Inbound. Your system calls OGIVS. The key identifies your organization and carries the scopes that bound what it may touch.
sk_live_A7f2Kq9x_…
Webhook secrets
Outbound. OGIVS calls your system. The secret is what deliveries are signed with, so your endpoint can prove a payload really came from us.
whsec_live_Bq4Lp2m8_…
Both are owned by an organization, both come in Test and
Live modes, and both follow the same disclosure rule: the
plaintext value is returned exactly once, at the moment it is minted.
Implementation status
Read this before integrating
Credential management is fully built. Credential consumption is not yet. Specifically, in the current codebase:
- • The only authentication scheme registered is JWT bearer. There is no
authentication handler that reads an API key from a request header, so presenting
sk_live_…to an endpoint will not authenticate you today. - • There is no webhook dispatcher. Secrets can be created, scoped, rotated and revoked, but nothing yet signs and delivers an outbound event.
Everything on this page that describes managing credentials is accurate against the running API. The signature-verification section is marked as forward-looking and describes the intended contract, not shipped behaviour.
Key anatomy
A key is minted from 32 cryptographically random bytes, base64-encoded with the non-alphanumeric characters stripped. The first eight characters of that secret are reused as a searchable prefix:
- sk
- Secret key marker. Webhook secrets use
whsec. - live
- Mode tag —
testorlive. - A7f2Kq9x
- Prefix. Stored in clear and returned by read endpoints as
keyPrefix, so a key can be identified in a list without exposing it. - secret
- The full random secret. Never stored.
Only a hash is persisted
The database holds the uppercase hex SHA-256 of the whole key string, plus the prefix. A lost key cannot be recovered by anyone, including platform staff — it can only be regenerated, which invalidates the old one.
Test & live modes
Mode is an enum taking Test or
Live. An organization may hold one active key per
mode — so one test key and one live key concurrently.
Attempting to create a second active key in a mode that already has one returns
409 Conflict. This is deliberate: replacing a key is an
explicit rotation, so a compromised key cannot quietly remain valid beside its
replacement. Use the regenerate endpoint instead.
Creating a key
The organization is resolved from the authenticated caller, never taken from the request
body — an organization admin cannot mint a key against somebody else's organization.
Requires the ApiKeysCreate permission.
Response — 201 Created
{
"id": "01a03d0e-b6b8-7d0b-a182-633ece0736aa",
"organizationId": "01a03d0e-9c21-7f4a-b8e2-1d4f8a2c6b90",
"mode": "Live",
"keyPrefix": "sk_live_A7f2Kq9x",
"scopes": ["guests:read", "stays:read", "stays:write"],
"isActive": true,
"createdAt": "2026-08-26T11:04:22.417+00:00",
"rawKey": "sk_live_A7f2Kq9x_A7f2Kq9xT3nR8vC1wE5yU0iO6pS4dF2gH9jK7lZ3xQ"
}
rawKey appears in this response and nowhere else.
Every read endpoint returns keyPrefix only. Store it before you
close the connection.
Scopes
At least one scope is required. Scopes are persisted as a comma-separated, deduplicated, ordinally sorted string, so two identical grants compare equal.
| Scope | Grants |
|---|---|
| guests:read | Read guest records |
| guests:write | Create and amend guests |
| listings:read | Read listings |
| listings:write | Create and amend listings |
| stays:read | Read stays and occupancy |
| stays:write | Book, check in, check out, cancel |
| transactions:read | Read transactions |
| transactions:write | Initiate transactions |
| wallets:read | Read wallet balances |
| webhooks:read | Read webhook secrets |
| webhooks:write | Manage webhook secrets |
Scopes on an existing key are replaced wholesale — the request body is the new complete set, not a delta:
Rotation & revocation
Regenerate
Mints fresh secret material on the same record and returns the new
rawKey once. The previous value stops working immediately —
there is no overlap window, so deploy the new key promptly.
POST /api-keys/{id}/regenerate
Revoke
Marks the key inactive and stamps revokedAt. The record
survives for audit; the credential does not. Use this when a key is compromised and
you do not want a replacement yet.
POST /api-keys/{id}/revoke
API key endpoints
All paths are relative to /api/v{version}. Available on v1 and v2.
/api-keysList keys (paged, sortable)/api-keysCreate — returns rawKey/api-keys/{id}Read one/api-keys/{id}/scopesReplace scopes/api-keys/{id}/regenerateRotate — returns rawKey/api-keys/{id}/revokeDeactivate/api-keys/{id}DeletePlatform administration
Acting on a named organization rather than the caller's own:
/organizations/{organizationId}/api-keysCreate for an organization/organizations/{organizationId}/api-keys/regenerateRotate for an organizationUsage & billing
Booking-desk calls are metered per organization and accrued onto a monthly usage row. Reads are charged as well as writes — an occupancy sheet costs the same lookup as the booking. Platform staff are excluded, since they act on organizations rather than as one.
/api-key-usagesUsage rows/api-key-usages/summaryAggregated, with groupingSigning secrets
A webhook secret is the shared key that outbound deliveries are signed with, so your
endpoint can verify a payload originated from OGIVS and was not altered in transit. It is
generated identically to an API key — 32 random bytes, prefix retained, SHA-256 hash
stored — but carries the whsec marker.
Unlike an API key, a secret is bound to an organization at creation and is always organization-scoped.
Event types
A secret may be narrowed to particular events. An empty event-type list means the secret covers every event — it is not an inert secret.
guest.createdguest.updatedstay.createdstay.checked_instay.checked_outtransaction.createdtransaction.settledtransaction.failedescrow.releasedEvent types are replaced wholesale, as scopes are:
Creating a secret
Response — 201 Created
{
"id": "01a03d0e-c4d2-7a1b-9f83-52ab7c9e4d11",
"organizationId": "01a03d0e-9c21-7f4a-b8e2-1d4f8a2c6b90",
"mode": "Live",
"secretPrefix": "whsec_live_Bq4Lp2m8",
"eventTypes": ["stay.checked_in", "stay.checked_out"],
"isActive": true,
"createdAt": "2026-08-26T11:07:03.882+00:00",
"rawSecret": "whsec_live_Bq4Lp2m8_Bq4Lp2m8N6vX1cZ8kW3rT5yH0jL9dG2fS7aQ4pM"
}
Verifying a delivery
No dispatcher ships in the current codebase, so nothing sends signed deliveries yet. When one lands, verification will follow the standard shape below — recompute the MAC over the raw request body and compare in constant time. Treat this as the intended contract rather than a live one, and confirm the header names against the release notes before relying on it.
import crypto from "node:crypto";
app.post("/webhooks/ogivs",
express.raw({ type: "application/json" }), // raw bytes, not parsed JSON
(req, res) => {
const signature = req.get("X-OGIVS-Signature");
const expected = crypto
.createHmac("sha256", process.env.OGIVS_WEBHOOK_SECRET)
.update(req.body)
.digest("hex");
const ok =
signature &&
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!ok) return res.status(401).send("bad signature");
const event = JSON.parse(req.body.toString());
// Handle, then acknowledge quickly.
res.status(204).end();
});
Two rules worth building in from the start: sign over the raw body, since re-serializing JSON changes the bytes and breaks the MAC; and acknowledge fast, doing real work on a queue, so a slow handler is not mistaken for a failed delivery.
Webhook secret endpoints
/webhook-secretsList (paged, sortable)/webhook-secretsCreate — returns rawSecret/webhook-secrets/{id}Read one/webhook-secrets/{id}/event-typesReplace event types/webhook-secrets/{id}/regenerateRotate — returns rawSecret/webhook-secrets/{id}/revokeDeactivate/webhook-secrets/{id}DeletePlatform administration
/organizations/{organizationId}/webhook-secretsCreate for an organization/organizations/{organizationId}/webhook-secrets/regenerateRotate for an organizationListings
A listing is one rentable space — a room, a hall, a pitch, a desk. Stays are booked against them, so an organization registers its spaces once and then books guests into them.
The type describes the space, not the organization. A hotel is a hotel, but it may list guest rooms and a conference hall side by side, and each is a listing of its own kind.
Room
A guest room let overnight — the usual hotel or guesthouse unit.
Apartment
A self-contained unit with its own facilities.
House
An entire building let as one unit.
EventSpace
An indoor space hired for events — a hall or conference room.
Field
An outdoor pitch or field, for football or recreation.
Workspace
A desk or room let for work.
Other
Anything else. listingTypeOther is then required.
Every listing also carries a publicId — a ULID minted by the
server. Use it when referring to a space from outside the system; the internal
id stays for API calls.
Adding spaces
One call adds one space, or a hundred. Everything except the codes is shared across the batch, so a hotel registers rooms 001–100 as a hundred listings that share a name, rate and description while each keeps its own room number.
Passing one code creates one listing; passing none creates a single listing with no code. Up to 500 codes in a request — generous for a large hotel, and a bound on what a mistyped range can insert.
| Field | Notes |
|---|---|
| name | Required. Up to 200 characters. Shared across the batch. |
| codes | One listing per code — the room or unit number. Optional. |
| listingType | One of the types above. listingTypeOther is required when it is Other. |
| organizationBranchId | Which branch the space belongs to. Optional. |
| capacity | How many it holds. Must be above zero when given. |
| baseRate | The price. Requires ratePeriod alongside it. |
| ratePeriod | What the rate buys: Hour, Morning, Afternoon, Evening, Day, Night, MultiDay or Session. |
A rate without its period is refused. 45,000 per hour and 45,000 per night are very different prices, and a space is not inherently one or the other — the same hall may be hired by the hour and the same room let by the night. So the period belongs to the price, and the two travel together.
The rate is copied onto a stay when the booking is taken, so repricing a space later
does not restate what an earlier guest was charged. Amend a listing with
PUT /listings/{id}.
Listing & filtering
Returns the calling organization's own spaces, paginated and newest first. Narrow with any combination of the filters below.
| Parameter | Narrows to |
|---|---|
| search | Name, code or description |
| listingType | One kind of space |
| branchId | One branch's spaces |
| isActive | Bookable spaces only, or withdrawn ones only |
| sort_column | With sort_order of Asc or Desc |
| page | With per_page. Defaults to 15 a page. |
A single space reads back by id at GET /listings/{id}, or by
its ULID at GET /listings/by-public-id/{publicId} — useful when
the reference came from a booking confirmation or a sign on the door rather than from
the API.
Availability
A space out of service is deactivated rather than deleted. Bookings already taken against it keep their history, and it can be brought back when the repairs are done.
Deactivate
Withdraws the space from booking. New stays against it are refused; existing ones are untouched.
POST /listings/{id}/deactivate
Activate
Puts it back into service, bookable again from that moment.
POST /listings/{id}/activate
Deleting is for a space added by mistake, not one taken out of service —
DELETE /listings/{id}.
Listing endpoints
/listingsList spaces, filtered and paged/listingsAdd one space, or a batch by code/listings/{id}Read one/listings/by-public-id/{publicId}Read one by its ULID/listings/{id}Amend a space/listings/{id}/activateReturn it to service/listings/{id}/deactivateWithdraw it from booking/listings/{id}Remove one added in errorRegistering a guest
Three calls, in order. Each one hands the next what it needs, so nothing is typed twice and the document a guest presented stays attached to the record it proves.
Before any of them, check whether the guest is already known. Looking someone up is free, whether by phone, email or a photograph of their document; reading their document and verifying the number are both charged. So the lookup is what saves a returning guest from being registered — and paid for — twice.
GET /guests/contact/…
POST /ocr/identity-documents/scan-image
→ found? book them in, stop here
POST /files
→ fileId
POST /ocr/identity-documents
→ identityRecordId
POST /guests
→ guest + verification
The guest and their document are created together.
There is no separate call to file a verification — posting to /guests
writes the guest, the verification record and the link to the identity in one
transaction. Either all of it lands or none of it does.
Before you start · Is the guest already known?
Two free lookups, either of which can end the whole exercise. A guest registered by any organization is the same person at yours — that is the point of guests being shared — so a returning traveller should be booked in, not registered again.
By phone number or email
Whichever detail the guest offered. Which one it is is worked out from the value, so
there is nothing to declare: an address contains an @ and a
number does not.
Numbers are matched in stored form, so +2348031234567 and
08031234567 find the same guest. An email is matched with its
domain lowercased. Responds 404 when nobody holds it — which is the
answer that means it is safe to register someone new.
By document image
When the guest has handed over a document rather than a phone number. Upload the photograph as in step 1, then pass its file id here: the number is read off the image and looked up in one call, so nobody types it. It asks whether that exact document is already on file, and where its checking stands.
This is the scan a desk should run before registering anyone: it is what tells you whether the person handing over a document is new, or already a guest somewhere and simply needs booking in. Skipping it does not stop registration — it only means paying to verify someone who was already on file.
Accepts the same two documents the reader handles anywhere else —
NationalIdentityNumber and
InternationalPassport.
Response — 200 OK
{
"found": true,
"documentType": "NationalIdentityNumber",
"documentNumber": "52306459347",
"guest": {
"id": "01a04911-c401-77ee-9ed4-2b24ffdef02a",
"fullName": "Kolade Oluwafemi",
"uniqueId": "01M11A2JHPR3RMHPAM03ZQW0XS",
"onboardedByOrganizationName": "Lagoon Suites"
},
"status": "Verified",
"isVerified": true,
"verifiedAt": "2026-08-28T11:04:22.417+00:00",
"documentExpiryDate": null,
"isExpired": false,
"identityRecordId": "01a048a8-73ad-7e00-a7ce-d007e825d3ac"
}
Once the image reads, this answers 200 either way — not finding a
document is an answer, not a failure, so branch on found
rather than on the status code. The number that was read comes back in
documentNumber whether or not anybody holds it, ready to
carry into the register flow.
Free
Nothing is charged and your wallet is not touched, however many times you ask. This only reports what the system already knew.
It is a lookup, not a verification: nothing is checked against an issuing authority
and no identity is stored. Step 2 is what does that, and step 2 is what is charged.
An image that cannot be read, or that carries no legible number, answers
422.
identityRecordId comes back even when no guest holds the document
A number verified before — read yesterday, the booking abandoned — still has its identity on file. Take that id straight to step 3 and register the guest against it: the verification is already paid for and does not happen again.
What to do with the answer
| Result | Next |
|---|---|
Guest found, document Verified | Book them in. Nothing further to register or pay for. |
Guest found, document Pending | Book them in — but a pending document allows one stay only until it is reviewed. |
Guest found, isExpired true | Ask for a current document and file it against the existing guest. |
Not found, but identityRecordId present | Skip to step 3 with that id. The document is already verified. |
| Nothing found | A new guest. Start at step 1. |
Step 1 · Upload the document
Post the photograph of the guest's document and keep the id that comes back. A JPEG or PNG photographed at a reasonable size reads best; the reader enlarges a small image before it gives up on it, but it cannot recover detail a camera never captured.
Only two documents can be read
Reading a document well means knowing its layout — where the number sits and what shape it is — and only these two are understood that closely. Anything else is refused at step 2 with a 400 rather than returned as a guess.
NationalIdentityNumber
The NIN slip. The eleven-digit number is found on the face, then checked against the issuing authority — so the result comes back verified.
InternationalPassport
The data page. Its machine-readable zone gives the names, passport number, nationality, date of birth, sex and expiry. There is no register to ask, so the reading is stored unverified and reviewed by hand.
A driver's licence, voter's card, national e-ID card or staff ID cannot be read yet.
To file one of those against a guest, upload it as the proof on
POST /guests and it will wait for a manual review.
Step 2 · Read & verify it
Send the file id and the document type. For a NIN this does two things in one call: it reads the number off the slip, then checks that number against the issuing authority and returns the register's own record of the person.
Response — 200 OK
{
"fileId": "01a04437-6a48-7de2-bc81-6b90da2f3b03",
"documentType": "NationalIdentityNumber",
"succeeded": true,
"verified": true,
"fromCache": false,
"identityRecordId": "01a048a8-73ad-7e00-a7ce-d007e825d3ac",
"firstName": "SAMUEL",
"middleName": "ADEKUNLE",
"lastName": "ODEJINMI",
"documentNumber": "52306459347",
"dateOfBirth": "1996-12-12",
"gender": "M",
"verification": { "isMasked": false, "photo": "/9j/4AAQ…", … },
"ocr": { "firstName": "KOLADE", "rawText": "…", … }
}
identityRecordId is what step 3 needs.
It points at the stored identity — the verified names, date of birth, photograph and
the slip it was all read from — so none of that has to be sent again.
The top-level fields carry the register's answer where a lookup succeeded; the
ocr block keeps what the reader made of the photograph
alongside it, so a desk can see where the card and the register disagree. On a
document type with no register to check against, the top-level fields are the reader's
own transcription and verified is false.
Reading a document is charged
The fee comes out of the organization's wallet against the active schedule. A number verified before is answered from storage without troubling the provider, and the fee is the same either way — what is charged for is the answer, not the round trip. A read that fails, or a number the register will not confirm, costs nothing, so a clearer photograph may be sent again for free.
Verifying a NIN with no image
When the desk has the eleven digits themselves — typed, read out, or copied from another record — rather than a slip to photograph. This checks the number against the issuing authority directly, skipping the reading step entirely, and returns exactly the record step 2 would have for the same NIN.
Response — 200 OK
{
"identityRecordId": "01a048a8-73ad-7e00-a7ce-d007e825d3ac",
"number": "52306459347",
"verified": true,
"fromCache": false,
"details": { "isMasked": false, "firstName": "SAMUEL", "lastName": "ODEJINMI", … }
}
identityRecordId is the same field step 2 returns and goes to
the same place — straight into step 3. There is no ocr block
here: nothing was read off an image, so there is nothing to compare the register's
answer against.
Priced and charged the same as reading a slip
The work behind it is the same question put to the same register; arriving by keyboard rather than photograph does not change what is being bought. A number verified before answers from storage at the same fee — what is charged for is the answer, not the round trip. A number the register will not confirm costs nothing, so a corrected number may be sent again for free.
Already registered to someone?
If this NIN is already attached to a guest, the response carries an
attachment field naming who holds it — the same answer
POST /ocr/identity-documents/scan-image gives for the number.
The fee is still charged, since the register still answered; book the guest named
there rather than registering a duplicate.
Step 3 · Register the guest
Pass the identityRecordId from step 2 along with the guest's
contact details. Either an email or a phone number is required.
documentNumber is not sent: it is taken from the identity
record, so what is filed is always the number that was actually verified.
proofId is optional too — leave it out and the slip already
held on the identity record is filed as the proof.
| Field | Notes |
|---|---|
| identityRecordId | Required. From step 2. Responds 404 if it does not exist. |
| documentType | Required. Decides whether the verification is filed verified or pending. |
| documentTypeOther | Required only when the type is Other. |
| documentExpiryDate | Optional. Refused if already past. A NIN does not expire. |
| proofId | Optional. Defaults to the image on the identity record. |
| guestAvatarId | Optional. A photograph of the guest taken at the desk. |
The guest's own identifier is minted by the server and cannot be supplied. Responds 409 when the email or phone already belongs to a guest, naming which of the two collided.
Verified or pending
What the document is decides where the verification starts, and that is the whole difference between the two paths.
NIN
Filed as Verified the moment the guest is registered,
with the timestamp recorded. Nobody reviews it: the identity record behind it is
the issuing authority's own answer, so there is nothing left to check.
The guest is usable immediately.
Every other document
A passport, licence, voter's card or staff ID is filed as
Pending. There is no register to check it against, so
OGIVS reviews it by hand and records the outcome.
The guest can be booked in meanwhile; the document simply sits unconfirmed until somebody at OGIVS looks at it.
The outcome of a manual review is recorded by OGIVS on
PATCH /guest-verifications/{id}/status, which moves a pending
document to Verified, Failed or
Expired. A failure carries a note saying why, so the desk
knows what to send instead.
Amending a document
A document that has not passed can be corrected or re-uploaded. One that has passed cannot.
Not yet verified —
Pending, Unverified,
Failed or Expired
Amendable. Send a clearer photograph, correct a mistyped number, or change the
document type on
PUT /guest-verifications/{id}. This is the path for a guest
with no NIN whose papers were not accepted first time: they may keep updating the
document until a review passes it.
Verified
Frozen. Amending is refused with 409, because what an admin checked must not change underneath the outcome they recorded. File a new document instead if the guest's papers have genuinely changed.
An identity in use cannot have its document swapped
Once a guest is registered against an identity, the image on file for it is the evidence they were checked against. Reading a different photograph for that same number afterwards is refused with 409 rather than allowed to overwrite it. Until a guest is attached, a clearer retake simply replaces what is stored.
Guest & verification endpoints
/guests/contact/{contact}Find a guest by phone or email/ocr/identity-documents/scan-imageIs the document in this image already on file?/ocr/identity-documentsRead a document, verify a NIN/verifications/ninVerify a NIN with no image/guestsRegister a guest and file their document/guestsList guests/guests/{id}Read one/guests/lookupFind by identifier/guests/{id}Amend a guest/guest-verificationsList documents on file/guest-verifications/{id}Amend one that has not passed/guest-verifications/{id}/statusRecord a review outcome (OGIVS)Guest stays
A stay is one booking: a guest, optionally a space, and the dates they hold it for. It is the record the desk works from and the one the law asks about, which is why a guest must be registered and their document filed before a stay can be taken against them.
Every endpoint in this section is scoped to the calling organization. A stay belonging
to somebody else reads back as 404 rather than
403 — an organization is not told which booking references
exist elsewhere.
Booking references
Every stay is given a bookingReference of the form
ORGCODE-20260831T142201 — the organization's public code
and the moment it was taken. It is what a guest quotes at the desk, and it reads back
directly at GET /stays/reference/{reference} without needing
the id.
Who may be booked in
A booking is refused unless the guest has an identity document on file. What that document is decides how far it goes.
Verified — books freely
A NIN is checked against the issuing authority as it is filed, so the guest is verified from the outset. One passed document is enough whatever else is on file: a lapsed passport beside a good NIN changes nothing.
Pending — one stay only
A passport has no register to check against and waits for OGIVS to review it. That cannot be hurried, and refusing outright would strand a traveller at the desk — so a pending document buys exactly one stay. The limit falls away once it passes.
The one-stay allowance counts stays that are not cancelled. A guest whose pending booking was cancelled may book again; one who is checked in may not, until the document is reviewed.
| Document state | Booking is |
|---|---|
| Nothing on file | Refused — register the document first |
Any document Verified | Allowed, without limit |
Pending, no active stay | Allowed, once |
Pending, one active stay | Refused until review completes |
Failed | Refused — file a replacement |
| Expired | Refused — file a current one |
A refusal arrives as 409 Conflict and says which of these it
was, so the message can be shown to the clerk as it stands.
Scan again at the desk, not just at registration
Having a document on file proves someone was checked once, not that the traveller in
front of you now is that person. Before taking the booking, scan the document they
are presenting today with
POST /ocr/identity-documents/scan-image — the same free
lookup used before registering anyone. A match confirms who you are booking in; a
mismatch means checking whether this is really the guest on file before
POST /stays is called.
Taking a booking
The guest and the dates are required. The space is not — a booking may be taken before a room is assigned, and the listing added later by amending the stay.
| Field | Required | Notes |
|---|---|---|
| guestId | Yes | Must be registered and eligible |
| checkInDate | Yes | UTC |
| checkOutDate | Yes | Must be later than check-in |
| listingId | No | Must be yours and active |
| rate | No | Defaults to the listing's base rate |
| ratePeriod | No | Defaults to the listing's period |
| totalAmount | No | Calculated when omitted |
Response — 200 OK
{
"id": "01a04b77-1f52-7a93-8c04-2ed9b7451a60",
"bookingReference": "HTL42-20260831T142201",
"organizationProfileId": "01a03f18-9c44-7bb1-a0d5-71c2e8930b4e",
"organizationCode": "HTL42",
"organizationName": "Ikoyi Suites",
"guest": { "id": "01a04a12-…", "firstName": "SAMUEL", "lastName": "ODEJINMI", "primaryPhone": "+2348012345678" },
"listing": { "id": "01a0491f-…", "publicId": "01K9WQ…", "name": "Deluxe Room", "code": "204" },
"checkInDate": "2026-09-04T14:00:00Z",
"checkOutDate": "2026-09-07T11:00:00Z",
"rate": 45000,
"ratePeriod": "Night",
"totalAmount": 135000,
"status": "Pending",
"createdAt": "2026-08-31T14:22:01.482Z"
}
Double bookings are checked, not locked
A space already held over those dates is refused with 409.
The check is not a lock, so two requests in the same instant can both pass it — if
you take bookings from more than one place at once, treat the space as provisional
until you have read it back.
Rates & totals
The rate and period are copied onto the stay when it is taken, not read from the listing afterwards. Repricing a room later does not restate what a guest was already charged.
Send totalAmount to charge an agreed figure. Omit it and the
total is worked out from the rate, the period and the dates — rounded up, so a stay
that runs into a further night is charged for it.
| ratePeriod | Total is |
|---|---|
| Hour | Rate × hours, rounded up |
| Day, Night | Rate × days, rounded up |
| Morning, Afternoon, Evening | Rate, charged once |
| MultiDay, Session | Rate, charged once for the booking |
With no rate at all, the total is null — a booking held before terms are agreed. The period belongs to the price, not to the space: the same hall may be hired by the hour one day and as a session the next.
Arrival & departure
A stay opens as Pending and moves forward one step at a time.
Each transition is its own endpoint, so a mistyped date cannot silently check somebody
out.
Pending ──check-in──▶ CheckedIn ──check-out──▶ CheckedOut
│ │
└──── cancel ──────────┘──▶ Cancelled
Check in
Only from Pending. Send
checkInDate to record an arrival that differs from the
booked time; omit it to stamp now.
POST /stays/{id}/check-in
Check out
Only from CheckedIn. A guest who never arrived is
cancelled, not checked out.
POST /stays/{id}/check-out
Cancel
From Pending or CheckedIn. A
completed stay cannot be cancelled — it happened. Cancelling twice is harmless.
POST /stays/{id}/cancel
Any transition out of turn is 409 Conflict, naming the state
the stay is actually in. Each returns the whole stay, so there is no need to read it
back afterwards.
Amending a booking
Dates, space, rate and total can be changed while a stay is
Pending or CheckedIn. A stay that
is checked out or cancelled is a record, and amending one would rewrite it —
409.
Omitted fields are left alone, so a single date can be moved without restating the booking. The dates are then checked against what is stored rather than against each other, so moving check-out behind an unchanged check-in is caught. Moving to a different space re-checks availability, and the space must be one of yours and active.
Listing & filtering
Returns the calling organization's own stays, paginated. Narrow with any combination of the filters below.
| Parameter | Narrows to |
|---|---|
| status | Pending, CheckedIn, CheckedOut or Cancelled |
| guestId | One guest's stay history |
| listingId | One space's bookings |
| from, to | A date window |
| search | Guest name, or booking reference |
| sortColumn | CheckInDate, CheckOutDate, TotalAmount, Status or CreatedAt, with sortOrder |
| page | With pageSize |
A single stay reads back at GET /stays/{id}, or by the
reference the guest quotes at
GET /stays/reference/{reference}.
Stay webhooks
Before relying on stay notifications, ask whether your organization is actually set up to receive them. The readiness endpoint answers in one call rather than leaving you to infer it from silence.
Response — 200 OK
{
"ready": false,
"hasLiveApiKey": true,
"hasLiveWebhookSecret": true,
"hasCallbackUrl": false,
"callbackUrl": null,
"secretPrefix": "whsec_live_9f2a",
"subscribedEvents": ["stay.created", "stay.checked_in"],
"missing": ["No callback URL is set on the live webhook secret"]
}
missing lists what is left to do in words, so a failing
integration can be diagnosed without reading each flag. All of live API key, live
webhook secret and callback URL must be in place before
ready is true.
Send a test event
Delivers a specimen stay.created to your callback URL so you
can confirm the endpoint answers and your signature check passes, without waiting for a
real booking.
{
"deliveryId": "01a04c03-5d19-7f44-9a6b-3c81e0d7b295",
"eventType": "stay.created",
"delivered": true,
"statusCode": 200,
"attempts": 1,
"latencyMs": 412,
"nextAttemptAt": null,
"error": null
}
Replay a stay
Re-sends the event for one real booking — for when your endpoint was down, or a delivery was lost. Replays carry the same payload as the original.
A delivery that fails is retried with a widening gap; attempts
and nextAttemptAt say where it has got to. If nothing is
configured, both endpoints answer 409 listing exactly what is
missing rather than reporting a delivery that never left.
Automatic stay events are not live yet
Booking, check-in and check-out do not currently dispatch a webhook on their own —
the three endpoints above are the only things that deliver a stay event today. Build
and verify your receiver against them, but poll
GET /stays for state you depend on rather than waiting to be
told. This page will be updated when automatic delivery is switched on.
Guest stay endpoints
/staysTake a booking/staysList stays, filtered and paged/stays/{id}Read one/stays/reference/{reference}Read one by booking reference/stays/{id}Amend dates, space, rate or total/stays/{id}/check-inRecord arrival/stays/{id}/check-outRecord departure/stays/{id}/cancelWithdraw a booking/guest-stays/webhookIs stay delivery configured?/guest-stays/webhook/testSend a specimen event/guest-stays/{stayId}/webhook/replayRe-send one stay's event
The stay endpoints need stays:read or
stays:write; the three webhook endpoints need
webhooks:read and webhooks:write.
Errors
Failures are rendered as RFC 7807 ProblemDetails by the global
exception handler.
| Status | Means |
|---|---|
| 400 | Validation failed — unknown scope or event type, empty scope list, mode outside the enum |
| 401 | Missing or invalid bearer token |
| 403 | Authenticated, but lacking the required permission |
| 404 | No such key or secret for this organization |
| 409 | Conflicts with something already on file — an active credential in that mode, a guest holding that email or phone, an identity already in use, or a verified document that cannot be amended |
| 422 | The image could not be read, or the number could not be verified |
Versioning
Every endpoint here is published on both v1 and v2. Select a version by URL segment,
/api/v1/…, or by the X-Api-Version
header. Responses report the versions they support.