Skip to content
OpenAppPhysical access, fully managed
Login

Integrate OpenApp with your existing software

Hotels, campuses, offices, short-term rentals, and locker / parcel operators already run systems of record — property management (PMS/VRMS), student information (SIS), workplace (IWMS), or custom parcel software. OpenApp is designed as an API-first access layer on top of that stack: your software stays authoritative for bookings and identities; OpenApp models who may open which door, when, and how guests are invited. Office corridors, campus stairs, and fire doors need a door-by-door class; many leaves must not be electrically locked via OpenApp. See Life safety and egress.

See Model by sector for the full sector list and which primary system pairs with each.

Webhook delivery is at-least-once, so the same booking event can arrive twice, out of order, or after a response was lost. Drive invitations from a system of record is the contract that makes those cases safe: reconcile by your own record id, idempotency keys, stale-event rejection, stable error codes, and rate limits. Build against it rather than sequencing create and update calls yourself.

There are two ways to connect a system of record:

A. Your service calls OpenApp
Your system (PMS / SIS / IWMS / custom)
│ webhook or scheduled job
▼
Your integration service ──▶ OpenApp HTTP API or SDK
│ invitations · entity actions · scripting
▼
Doors, gates, intercom portals, audit
B. Your PMS posts to OpenApp (native PMS connector)
PMS ── POST /pms/{connector_id}/inbound ──▶ audit event pms.reservation.*
│
▼
Hosted skill runs as an agent

Pattern A covers the whole invitation lifecycle and is what the rest of this page describes. Pattern B is the native PMS connector: OpenApp receives the booking event itself and records it, and a hosted skill can act on it.

Typical responsibilities:

Your systemOpenApp
Reservation / enrollment datesTime-bound access invites
Expected guest occupancy / sharing policymax_devices for unique guest browsers/devices; max_uses for total invite usage
Room or unit identifierPortal ids, entity ids, zones (mapped once at setup)
Reservation id and its revisionexternal_ref on the invitation — the addressable key for reconcile and the guard against late events
Checkout / departure eventReconcile to state: revoked (reversible, keeps history); DELETE only when the record must disappear
Retry / dedupe of your own eventsIdempotency-Key + X-Correlation-Id per event
Staff and resident directory (optional)Site people (/integrations/{id}/people) and listing members

On check-in (webhook): create an access invite whose schedules entry is aligned to the stay dates; send the guest the public invite URL or deep link. Set max_devices from the reservation occupancy when one booking should support several family or group members—for example, 4 for a four-person booking. Set max_uses separately if total openings or redemptions must also be capped.

The browser stores a persistent per-device identifier, so forwarding the URL to an additional device consumes another device slot while refreshing or reopening it on an already registered device does not. This preserves frictionless shared use for the booked group while limiting accidental or abusive forwarding.

On checkout: reconcile the reservation to state: revoked. Revocation is idempotent and reversible, so a duplicate checkout webhook is safe and a reinstated booking restores the same invitation. Reserve DELETE for records that must not survive at all.

Pair with Public Access and the STR portfolio playbook.

Same invite pattern as STR, often with additional staff roles and lobby portals. See Boutique hotel playbook for unified orchestration alongside room-lock vendors.

Map dorm or building to an OpenApp org or sub-org. Provision people via Site people or org user APIs when the SIS emits enrollment changes.

Use OpenApp Scripting for bulk term rollover if many buildings share one template.

IWMS often owns badging and occupancy; OpenApp can orchestrate physical actuators (doors, lifts, parking gates) when IWMS events fire. Prefer read-only discovery agents first; gate switchable.open behind IWMS-approved states.

Model each compartment as an entity (or scripted matrix). Release on pickup code validation in your app → POST .../actions/switchable.open on the compartment entity.

A PMS connector gives your PMS an inbound URL on OpenApp itself, so you don’t need to host a receiver just to get booking events into OpenApp.

  1. Create a connector (requires api_keys:create in the org):

    Terminal window
    curl -sS -X POST \
    -H "X-API-Key: ${OPENAPP_API_KEY}" \
    -H "Content-Type: application/json" \
    -H "X-Org: ${OPENAPP_ORG_ID}" \
    -d '{ "name": "Front desk PMS", "provider": "generic" }' \
    "${OPENAPP_API_BASE}/orgs/${OPENAPP_ORG_ID}/connectors/pms"

    The response carries id, webhook_url_path (/pms/{connector_id}/inbound), and a signing_secret that is shown once. OpenApp keeps the secret in its secrets store and never returns it again.

  2. Point the PMS at POST {OPENAPP_API_BASE}/pms/{connector_id}/inbound with a JSON body such as { "event": "checkout", "reservation_id": "4821" }, and sign every request. The secret itself is never sent:

    HeaderValue
    X-OpenApp-TimestampCurrent Unix time in seconds
    X-OpenApp-Signaturesha256= + lowercase hex HMAC-SHA256 of {timestamp}.{raw body}, keyed with signing_secret
    Terminal window
    BODY='{"event":"checkout","reservation_id":"4821"}'
    TS="$(date +%s)"
    SIG="$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$PMS_SIGNING_SECRET" -hex | sed 's/^.* //')"
    curl -sS -X POST \
    -H "Content-Type: application/json" \
    -H "X-OpenApp-Timestamp: ${TS}" \
    -H "X-OpenApp-Signature: sha256=${SIG}" \
    -d "$BODY" \
    "${OPENAPP_API_BASE}/pms/${CONNECTOR_ID}/inbound"

    Sign the exact bytes you send. A missing or wrong signature, a timestamp more than 5 minutes from OpenApp’s clock, or a repeat of a request already accepted returns 401. Accepted events return 202. To retry a delivery, sign it again with a new timestamp.

    GET /orgs/{org_id}/connectors/pms lists the org’s connectors (never their secrets). To retire a leaked secret, create a new connector, point the PMS at it, then delete the old one with DELETE /orgs/{org_id}/connectors/pms/{connector_id}. Deleting removes the signing secret, and the old URL returns 404 from then on.

  3. Each event is recorded as an audit event: pms.reservation.checkout when event is checkout, otherwise pms.reservation.event. It’s queryable and delivered to audit webhooks like any other event.

  4. To act on it, create a hosted skill (POST /orgs/{org_id}/skills) for an agent with trigger_event_types: ["pms.reservation.checkout"]. The skill runner never auto-commits physical actions such as switchable.open.

SDK

import os
from datetime import datetime, timezone
from openapp_sdk import AsyncClient
INTEGRATION_ID = os.environ["VIRTUAL_ACCESS_INTEGRATION_ID"]
LOBBY_PORTAL_ID = os.environ["LOBBY_PORTAL_ID"]
async def on_reservation_event(
client: AsyncClient,
event_id: str,
reservation_id: str,
revision: int,
arrival: datetime,
departure: datetime,
) -> None:
"""One call per booking event: reconcile is safe to repeat and rejects late events."""
# No dedicated reconcile helper yet; call the route through the client transport.
await client._request(
"POST",
f"/integrations/{INTEGRATION_ID}/access-invites/reconcile",
headers={"Idempotency-Key": event_id, "X-Correlation-Id": event_id},
body={
"external_ref": {"source": "pms", "record_id": reservation_id, "revision": revision},
"state": "active",
"invitation": {
"portal_ids": [LOBBY_PORTAL_ID],
"name": f"Stay {reservation_id}",
"schedules": [{
"valid_from": arrival.astimezone(timezone.utc).isoformat(),
"valid_to": departure.astimezone(timezone.utc).isoformat(),
}],
"max_devices": 4,
"max_uses": 50,
},
},
)
# client = await AsyncClient.connect(api_key=os.environ["OPENAPP_API_KEY"], org_id=org_id)

Every tab calls reconcile once per booking event: it’s safe to repeat, rejects late events by revision, and needs no OpenApp invitation id. On checkout, send the same call with state: "revoked".

Python (HTTP API)

import os
from datetime import datetime, timezone
import httpx
API_BASE = os.environ["OPENAPP_API_BASE"]
API_KEY = os.environ["OPENAPP_API_KEY"]
ORG = os.environ["OPENAPP_ORG_ID"]
INTEGRATION_ID = os.environ["VIRTUAL_ACCESS_INTEGRATION_ID"]
LOBBY_PORTAL_ID = os.environ["LOBBY_PORTAL_ID"]
async def on_reservation_event(
event_id: str,
reservation_id: str,
revision: int,
arrival: datetime,
departure: datetime,
):
"""One call per booking event: reconcile is safe to repeat and rejects late events."""
body = {
"external_ref": {
"source": "pms",
"record_id": reservation_id,
"revision": revision,
},
"state": "active",
"invitation": {
"portal_ids": [LOBBY_PORTAL_ID],
"name": f"Stay {reservation_id}",
"schedules": [
{
"valid_from": arrival.astimezone(timezone.utc).isoformat(),
"valid_to": departure.astimezone(timezone.utc).isoformat(),
}
],
"max_devices": 4,
"max_uses": 50,
},
}
async with httpx.AsyncClient() as http:
await http.post(
f"{API_BASE}/integrations/{INTEGRATION_ID}/access-invites/reconcile",
headers={
"x-api-key": API_KEY,
"content-type": "application/json",
"x-org": ORG,
"idempotency-key": event_id,
"x-correlation-id": event_id,
},
json=body,
)

HTTP API (curl)

Terminal window
curl -sS -X POST \
-H "X-API-Key: ${OPENAPP_API_KEY}" \
-H "Content-Type: application/json" \
-H "X-Org: ${OPENAPP_ORG_ID}" \
-d '{
"portal_ids": ["'"${PORTAL_ID}"'"],
"name": "PMS stay 4821",
"schedules": [
{ "valid_from": "2026-06-01T15:00:00Z", "valid_to": "2026-06-05T11:00:00Z" }
],
"max_devices": 4,
"max_uses": 20
}' \
"${OPENAPP_API_BASE}/integrations/${INTEGRATION_ID}/access-invites"

The response includes invite_token for guest URLs — distribute through your PMS guest messaging, not in public logs. It is returned once; see token handling.