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.
Integration pattern
Section titled “Integration pattern”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 agentPattern 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 system | OpenApp |
|---|---|
| Reservation / enrollment dates | Time-bound access invites |
| Expected guest occupancy / sharing policy | max_devices for unique guest browsers/devices; max_uses for total invite usage |
| Room or unit identifier | Portal ids, entity ids, zones (mapped once at setup) |
| Reservation id and its revision | external_ref on the invitation — the addressable key for reconcile and the guard against late events |
| Checkout / departure event | Reconcile to state: revoked (reversible, keeps history); DELETE only when the record must disappear |
| Retry / dedupe of your own events | Idempotency-Key + X-Correlation-Id per event |
| Staff and resident directory (optional) | Site people (/integrations/{id}/people) and listing members |
Sector examples
Section titled “Sector examples”Short-term rental (VRMS / PMS)
Section titled “Short-term rental (VRMS / PMS)”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.
Hotel (PMS)
Section titled “Hotel (PMS)”Same invite pattern as STR, often with additional staff roles and lobby portals. See Boutique hotel playbook for unified orchestration alongside room-lock vendors.
Campus (SIS)
Section titled “Campus (SIS)”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.
Office (IWMS)
Section titled “Office (IWMS)”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.
Parcel / locker matrix
Section titled “Parcel / locker matrix”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.
Native PMS connector
Section titled “Native PMS connector”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.
-
Create a connector (requires
api_keys:createin 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 asigning_secretthat is shown once. OpenApp keeps the secret in its secrets store and never returns it again. -
Point the PMS at
POST {OPENAPP_API_BASE}/pms/{connector_id}/inboundwith a JSON body such as{ "event": "checkout", "reservation_id": "4821" }, and sign every request. The secret itself is never sent:Header Value X-OpenApp-TimestampCurrent Unix time in seconds X-OpenApp-Signaturesha256=+ lowercase hex HMAC-SHA256 of{timestamp}.{raw body}, keyed withsigning_secretTerminal 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 return202. To retry a delivery, sign it again with a new timestamp.GET /orgs/{org_id}/connectors/pmslists 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 withDELETE /orgs/{org_id}/connectors/pms/{connector_id}. Deleting removes the signing secret, and the old URL returns404from then on. -
Each event is recorded as an audit event:
pms.reservation.checkoutwheneventischeckout, otherwisepms.reservation.event. It’s queryable and delivered to audit webhooks like any other event. -
To act on it, create a hosted skill (
POST /orgs/{org_id}/skills) for an agent withtrigger_event_types: ["pms.reservation.checkout"]. The skill runner never auto-commits physical actions such asswitchable.open.
Minimal webhook handler (Python)
Section titled “Minimal webhook handler (Python)”SDK
import osfrom 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)/** One call per booking event: reconcile is safe to repeat and rejects late events. */async function onReservationEvent( eventId: string, reservationId: string, revision: number, arrival: Date, departure: Date,): Promise<void> { const res = await fetch( `${apiBase}/integrations/${integrationId}/access-invites/reconcile`, { method: "POST", headers: { "x-api-key": apiKey, "content-type": "application/json", "x-org": orgId, "idempotency-key": eventId, "x-correlation-id": eventId, }, body: JSON.stringify({ external_ref: { source: "pms", record_id: reservationId, revision }, state: "active", invitation: { portal_ids: [lobbyPortalId], name: `Stay ${reservationId}`, schedules: [ { valid_from: arrival.toISOString(), valid_to: departure.toISOString() }, ], max_devices: 4, max_uses: 50, }, }), }, ); if (!res.ok) throw new Error(`reconcile failed: ${res.status}`);}use chrono::{DateTime, Utc};use openapp_sdk::{Client, transport::RequestSpec};use reqwest::Method;use serde_json::json;
/// One call per booking event: reconcile is safe to repeat and rejects late events.async fn on_reservation_event( client: &Client, integration_id: &str, lobby_portal_id: &str, event_id: &str, reservation_id: &str, revision: i64, arrival: DateTime<Utc>, departure: DateTime<Utc>,) -> Result<(), openapp_sdk::SdkError> { let body = json!({ "external_ref": { "source": "pms", "record_id": reservation_id, "revision": revision }, "state": "active", "invitation": { "portal_ids": [lobby_portal_id], "name": format!("Stay {reservation_id}"), "schedules": [{ "valid_from": arrival.to_rfc3339(), "valid_to": departure.to_rfc3339(), }], "max_devices": 4, "max_uses": 50, }, }); // No dedicated reconcile helper yet; call the route through the transport. let path = format!("/integrations/{integration_id}/access-invites/reconcile"); let _: serde_json::Value = client .transport() .request_json(RequestSpec { method: Method::POST, path: &path, body: Some(&body), extra_headers: &[ ("Idempotency-Key", event_id.to_string()), ("X-Correlation-Id", event_id.to_string()), ], ..Default::default() }) .await?; Ok(())}func onReservationEvent( ctx context.Context, client *openapiclient.APIClient, orgID, integrationID, lobbyPortalID string, eventID, reservationID string, revision int64, arrival, departure time.Time,) error { stay := openapiclient.NewInviteScheduleEntryInput() stay.SetValidFrom(arrival.UTC().Format(time.RFC3339)) stay.SetValidTo(departure.UTC().Format(time.RFC3339))
invitation := openapiclient.NewCreateAccessInviteRequest( []string{lobbyPortalID}, []openapiclient.InviteScheduleEntryInput{*stay}, ) invitation.SetName(fmt.Sprintf("Stay %s", reservationID)) invitation.SetMaxDevices(int32(4)) invitation.SetMaxUses(int32(50))
ref := openapiclient.NewExternalReferenceInput(reservationID, "pms") ref.SetRevision(revision) body := openapiclient.NewReconcileAccessInviteRequest(*ref) body.SetState(openapiclient.RECONCILEINVITESTATE_ACTIVE) body.SetInvitation(*invitation)
_, httpResp, err := client.IntegrationsAPI.ReconcileIntegrationAccessInvite(ctx, integrationID). XOrg(orgID). IdempotencyKey(eventID). XCorrelationId(eventID). ReconcileAccessInviteRequest(*body). Execute() if err != nil { return err } defer httpResp.Body.Close() return nil}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 osfrom 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 proof (curl)
Section titled “HTTP proof (curl)”HTTP API (curl)
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.
Related docs
Section titled “Related docs”- Drive invitations from a system of record — reconcile, idempotency, error codes, rate limits.
- AI agents overview — API vs SDK vs scripting.
- Build an access-control agent — keys,
X-Org, entity actions. - Integrations SDK — portals and invite maintenance.
- OpenAPI bundle for codegen and contract tests.