OGIVS

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_live_A7f2Kq9x_A7f2Kq9xT3nR8vC1wE5yU0iO6pS4dF2gH9jK7lZ3xQ
sk
Secret key marker. Webhook secrets use whsec.
live
Mode tag — test or live.
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.

ScopeGrants
guests:readRead guest records
guests:writeCreate and amend guests
listings:readRead listings
listings:writeCreate and amend listings
stays:readRead stays and occupancy
stays:writeBook, check in, check out, cancel
transactions:readRead transactions
transactions:writeInitiate transactions
wallets:readRead wallet balances
webhooks:readRead webhook secrets
webhooks:writeManage 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.

GET/api-keysList keys (paged, sortable)
POST/api-keysCreate — returns rawKey
GET/api-keys/{id}Read one
PUT/api-keys/{id}/scopesReplace scopes
POST/api-keys/{id}/regenerateRotate — returns rawKey
POST/api-keys/{id}/revokeDeactivate
DELETE/api-keys/{id}Delete

Platform administration

Acting on a named organization rather than the caller's own:

POST/organizations/{organizationId}/api-keysCreate for an organization
POST/organizations/{organizationId}/api-keys/regenerateRotate for an organization

Usage & 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.

GET/api-key-usagesUsage rows
GET/api-key-usages/summaryAggregated, with grouping

Signing 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.created
guest.updated
stay.created
stay.checked_in
stay.checked_out
transaction.created
transaction.settled
transaction.failed
escrow.released

Event 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"
}
Planned

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

GET/webhook-secretsList (paged, sortable)
POST/webhook-secretsCreate — returns rawSecret
GET/webhook-secrets/{id}Read one
PUT/webhook-secrets/{id}/event-typesReplace event types
POST/webhook-secrets/{id}/regenerateRotate — returns rawSecret
POST/webhook-secrets/{id}/revokeDeactivate
DELETE/webhook-secrets/{id}Delete

Platform administration

POST/organizations/{organizationId}/webhook-secretsCreate for an organization
POST/organizations/{organizationId}/webhook-secrets/regenerateRotate for an organization

Listings

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.

FieldNotes
nameRequired. Up to 200 characters. Shared across the batch.
codesOne listing per code — the room or unit number. Optional.
listingTypeOne of the types above. listingTypeOther is required when it is Other.
organizationBranchIdWhich branch the space belongs to. Optional.
capacityHow many it holds. Must be above zero when given.
baseRateThe price. Requires ratePeriod alongside it.
ratePeriodWhat 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.

ParameterNarrows to
searchName, code or description
listingTypeOne kind of space
branchIdOne branch's spaces
isActiveBookable spaces only, or withdrawn ones only
sort_columnWith sort_order of Asc or Desc
pageWith 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

GET/listingsList spaces, filtered and paged
POST/listingsAdd one space, or a batch by code
GET/listings/{id}Read one
GET/listings/by-public-id/{publicId}Read one by its ULID
PUT/listings/{id}Amend a space
POST/listings/{id}/activateReturn it to service
POST/listings/{id}/deactivateWithdraw it from booking
DELETE/listings/{id}Remove one added in error

Registering 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.

First · free
Look them up

GET /guests/contact/…

POST /ocr/identity-documents/scan-image

→ found? book them in, stop here

Step 1
Upload

POST /files

fileId

Step 2
Read & verify

POST /ocr/identity-documents

identityRecordId

Step 3
Register

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

ResultNext
Guest found, document VerifiedBook them in. Nothing further to register or pay for.
Guest found, document PendingBook them in — but a pending document allows one stay only until it is reviewed.
Guest found, isExpired trueAsk for a current document and file it against the existing guest.
Not found, but identityRecordId presentSkip to step 3 with that id. The document is already verified.
Nothing foundA 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.

FieldNotes
identityRecordIdRequired. From step 2. Responds 404 if it does not exist.
documentTypeRequired. Decides whether the verification is filed verified or pending.
documentTypeOtherRequired only when the type is Other.
documentExpiryDateOptional. Refused if already past. A NIN does not expire.
proofIdOptional. Defaults to the image on the identity record.
guestAvatarIdOptional. 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.

Verified

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.

Pending

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

GET/guests/contact/{contact}Find a guest by phone or email
POST/ocr/identity-documents/scan-imageIs the document in this image already on file?
POST/ocr/identity-documentsRead a document, verify a NIN
POST/verifications/ninVerify a NIN with no image
POST/guestsRegister a guest and file their document
GET/guestsList guests
GET/guests/{id}Read one
GET/guests/lookupFind by identifier
PUT/guests/{id}Amend a guest
GET/guest-verificationsList documents on file
PUT/guest-verifications/{id}Amend one that has not passed
PATCH/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 stateBooking is
Nothing on fileRefused — register the document first
Any document VerifiedAllowed, without limit
Pending, no active stayAllowed, once
Pending, one active stayRefused until review completes
FailedRefused — file a replacement
ExpiredRefused — 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.

FieldRequiredNotes
guestIdYesMust be registered and eligible
checkInDateYesUTC
checkOutDateYesMust be later than check-in
listingIdNoMust be yours and active
rateNoDefaults to the listing's base rate
ratePeriodNoDefaults to the listing's period
totalAmountNoCalculated 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.

ratePeriodTotal is
HourRate × hours, rounded up
Day, NightRate × days, rounded up
Morning, Afternoon, EveningRate, charged once
MultiDay, SessionRate, 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.

ParameterNarrows to
statusPending, CheckedIn, CheckedOut or Cancelled
guestIdOne guest's stay history
listingIdOne space's bookings
from, toA date window
searchGuest name, or booking reference
sortColumnCheckInDate, CheckOutDate, TotalAmount, Status or CreatedAt, with sortOrder
pageWith 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

POST/staysTake a booking
GET/staysList stays, filtered and paged
GET/stays/{id}Read one
GET/stays/reference/{reference}Read one by booking reference
PUT/stays/{id}Amend dates, space, rate or total
POST/stays/{id}/check-inRecord arrival
POST/stays/{id}/check-outRecord departure
POST/stays/{id}/cancelWithdraw a booking
GET/guest-stays/webhookIs stay delivery configured?
POST/guest-stays/webhook/testSend a specimen event
POST/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.

StatusMeans
400Validation failed — unknown scope or event type, empty scope list, mode outside the enum
401Missing or invalid bearer token
403Authenticated, but lacking the required permission
404No such key or secret for this organization
409Conflicts 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
422The 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.

OGIVS API documentation. Generated against the credential-management endpoints as implemented.