Try the Oste API in 5 minutes (for Apaleo engineers)
Oste is the operations layer for hotels on Apaleo: rooms and housekeeping, work, handovers, staff. This page takes you from nothing to a signed webhook arriving on your laptop, against a sandbox copy of our demo hotel, Hotel Bergland (72 rooms, fictional data only). Nothing you do in the sandbox reaches a real PMS or a real guest, and one call puts it back to the start.
- Reference:
GET /docson the API (interactive; paste your key and try every call) · spec:GET /openapi.json - Status:
GET /status· Errors:docs/api/ERRORS.md(everytypeURI has a section) - Design record:
docs/adr/ADR-019-developer-platform.md
0. Get an API (1 minute)
Hosted (staging): we send you the API URL and a sandbox test key (opk_test_…); skip to step 1 with export OPMISE_API=<url> OPMISE_KEY=<key> OPMISE_ORG=org_5yXD9AN9R5h5GLCh3K6XYF OPMISE_PROPERTY=prop_0PkTNKjJxMswl1PrHURYCj.
On your laptop (Node 22, pnpm 9, Postgres 17 on your PATH):
git clone https://github.com/TToldi/Opmise && cd Opmise
pnpm install
pnpm dev # Postgres, migrations, the Bergland seed, API :8787, worker, web :5173
# in a second terminal, once the API is up:
eval "$(pnpm -s dev:key)" # prints and exports OPMISE_API, OPMISE_KEY, OPMISE_ORG, OPMISE_PROPERTY
You can also create keys by hand: open http://localhost:5173, sign in as Group Owner, switch to Bergland Hotel Group (Sandbox), Settings › Connections › Oste API › Create a key. A key is shown once; we keep only a keyed hash of it.
1. First calls (1 minute)
alias api='curl -sS -H "Authorization: Bearer $OPMISE_KEY"'
api "$OPMISE_API/v1/me" | jq # who am I: the key, its org, scopes, mode (test)
api "$OPMISE_API/v1/orgs/$OPMISE_ORG/properties/$OPMISE_PROPERTY/rooms?limit=5" | jq '.data[] | {number, status}'
api -i "$OPMISE_API/v1/orgs/$OPMISE_ORG/properties/$OPMISE_PROPERTY/rooms?limit=1" | grep -i '^ratelimit'
# RateLimit-Policy: "test-key";q=20;w=2 RateLimit: "test-key";r=19;t=1
Lists are {object: "list", data, hasMore, nextCursor}; pass cursor=<nextCursor> for the next page. Every object has a prefixed id (rm_…, wk_…) and a version that is also its ETag.
2. Act on the hotel (1 minute)
Every POST from a key needs an Idempotency-Key (a retry with the same key and body replays the first answer for 7 days); every PATCH/PUT/DELETE needs If-Match with the version you read.
ROOM=$(api "$OPMISE_API/v1/orgs/$OPMISE_ORG/properties/$OPMISE_PROPERTY/rooms?limit=1" | jq -r '.data[0].id')
# Housekeeping: mark the room clean.
api -X POST "$OPMISE_API/v1/orgs/$OPMISE_ORG/properties/$OPMISE_PROPERTY/rooms/$ROOM/set_cleanliness" \
-H 'Content-Type: application/json' -H "Idempotency-Key: $(node -p "crypto.randomUUID()")" \
-d '{"cleanliness":"CLEAN"}' | jq '{number, status, stateVersion}'
# Work: file a snag in that room.
api -X POST "$OPMISE_API/v1/orgs/$OPMISE_ORG/properties/$OPMISE_PROPERTY/work_items" \
-H 'Content-Type: application/json' -H "Idempotency-Key: $(node -p "crypto.randomUUID()")" \
-d "{\"type\":\"SNAG\",\"title\":\"Wobbly desk chair\",\"department\":\"MAINTENANCE\",\"roomId\":\"$ROOM\"}" | jq '{id, status}'
# What happened, as events (30 days, thin: ids + non-personal changes).
api "$OPMISE_API/v1/orgs/$OPMISE_ORG/events?limit=5" | jq '.data[] | {type, id}'
3. Receive signed webhooks (2 minutes)
# Terminal A: a receiver that verifies every delivery (Standard Webhooks) and prints it.
OPMISE_WEBHOOK_SECRET=<fill in after the next call> pnpm dev:receiver # http://127.0.0.1:4242/hooks
# Terminal B: register it (local receivers are allowed in development only; hosted APIs need public HTTPS on 443).
api -X POST "$OPMISE_API/v1/orgs/$OPMISE_ORG/webhook_endpoints" \
-H 'Content-Type: application/json' \
-d '{"url":"http://127.0.0.1:4242/hooks","eventTypes":["*"]}' | jq '{id: .webhookEndpoint.id, secret}'
# → copy the whsec_… secret (shown once), restart terminal A with it, then:
api -X POST "$OPMISE_API/v1/orgs/$OPMISE_ORG/webhook_endpoints/<whk_id>/test" -H "Idempotency-Key: $(node -p "crypto.randomUUID()")" | jq
Terminal A prints webhook_endpoint.pinged … signature=VALID. Mark another room clean (step 2) and room.cleanliness_changed arrives within seconds.
What you get on every delivery:
| Header | Meaning |
|---|---|
webhook-id | the event id (evt_…): the same on every retry and resend. Dedupe on it. |
webhook-timestamp | Unix seconds; reject anything older than 5 minutes |
webhook-signature | v1,<base64 HMAC-SHA256(key, "{id}.{timestamp}.{body}")>, key = base64-decoded part after whsec_; two signatures (space-separated) during a secret rotation |
opmise-event-type | the event type, for routing before you parse |
Any Standard Webhooks library verifies these. By hand (Node):
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verify(whsec, id, ts, sigHeader, rawBody) {
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
const want = createHmac('sha256', Buffer.from(whsec.slice(6), 'base64')).update(`${id}.${ts}.${rawBody}`).digest()
return sigHeader.split(' ').some((s) => {
const got = Buffer.from(s.slice(3), 'base64')
return s.startsWith('v1,') && got.length === want.length && timingSafeEqual(got, want)
})
}
Answer 2xx quickly and do the work afterwards. We retry anything else at +1 min, 5 min, 30 min, 2 h, 6 h and then every 12 h (10 attempts within 3 days; an endpoint still failing after 3 days is disabled); 410 Gone disables the endpoint at once; 429 with Retry-After is honoured; redirects are not followed. The delivery log shows every attempt:
api "$OPMISE_API/v1/orgs/$OPMISE_ORG/webhook_endpoints/<whk_id>/deliveries" | jq '.data[] | {eventType, status, attemptCount, lastStatusCode}'
api -X POST "$OPMISE_API/v1/orgs/$OPMISE_ORG/webhook_deliveries/<whd_id>/resend" -H "Idempotency-Key: $(node -p "crypto.randomUUID()")"
api -X POST "$OPMISE_API/v1/orgs/$OPMISE_ORG/webhook_endpoints/<whk_id>/rotate_secret" -H 'Content-Type: application/json' -d '{}'
An endpoint your key creates belongs to your key: other keys cannot see it, and it only ever receives events your key may read (its scopes and hotels, checked on every event). Revoke the key and its deliveries stop.
4. Start over (10 seconds)
api -X POST "$OPMISE_API/v1/orgs/$OPMISE_ORG/sandbox/reset" | jq
# {"object":"sandbox_reset","template":"bergland","businessDate":"…"}: rooms, work and reservations are back at the
# template, moved to today. Your keys, endpoints and delivery log stay. Once a minute; 404 in live organizations.
The rules, in one place
- Auth:
Authorization: Bearer opk_test_…(sandbox only) oropk_live_…(live hotels only). A wrong, expired or revoked key is401withWWW-Authenticate; a missing scope is403 insufficient_scopenaming the scope. Keys can do at most what the person who created them can do, and never human-only things (access, billing, privacy, approvals, installing apps). - Errors: RFC 9457
application/problem+json:type(a documentation URL),title,status,detail,code(stable, switch on it),requestId(quote it to us),errors[]with JSON pointers for field problems. - Rate limits:
RateLimit-PolicyandRateLimiton every response (IETF draft-09 syntax). Test keys: 10/s, burst 20. Live keys: 100/s, burst 200.429 rate_limitedcarriesRetry-After. - Versions: send
Opmise-Version: 2026-10-01to pin; each key is pinned to the version current when it was created. Breaking changes only ever come with a new version and a transform. - Concurrency:
ETagon reads,If-Matchon writes (412 version_mismatchwithcurrentVersion). - Privacy: payloads and events carry no personal data; guest data needs its own scopes and the hotel's consent.
- PMS-neutral: nothing you receive names a PMS; ask
GET …/properties/{id}/capabilitieswhat the hotel's system can do before offering an action (422 capability_unsupportedotherwise).