Skip to content
OpenAppPhysical access, fully managed
Login

Drive invitations from a system of record

A system of record — PMS/VRMS, SIS, IWMS, or your own backend — owns bookings, enrolments, and work orders. OpenApp owns physical access. This guide is the contract between the two when your service drives guest access over the HTTP API: create on check-in, change on room move, revoke on checkout.

It is written for the failure modes that separate systems actually have: webhooks delivered twice, events delivered out of order, responses lost after the write succeeded, and the adapter’s local database lost. Everything below is enforced by the API, not by convention.

For the basic invitation shape (windows, portals, device and use limits) start with Create a time-bound guest invitation.

ValueSource
API keyCreate an API key
Organization id (X-Org)Dashboard or GET /orgs
Virtual Access integration idDashboard → Integrations
Portal id(s)Organization resources → Portals

The credential is bound to an organization; every invitation route authorizes against the integration’s organization, never the X-Org header alone. A portal id from another organization or another integration is reported as not found (invitation_portal_not_found), so a credential cannot reach across tenants by guessing ids.

Send external_ref with every write. It is a constrained model, not a metadata blob:

FieldMeaningRules
sourceThe system that owns the record (mews, opera, custom-pms)A slug: starts with a lowercase letter or digit, then lowercase letters, digits, ., _, -; ≤ 64 chars
record_idThe record inside that system (reservation, enrolment, work order)Opaque to OpenApp; printable, 1–128 chars
revisionThat record’s monotonic versionInteger ≥ 0; optional but strongly recommended

One invitation owns a (source, record_id) pair per integration, for as long as that invitation exists. That single rule is what makes retries safe: a second create for the same reservation is refused with external_reference_conflict instead of quietly issuing a second guest link. The pair stays claimed after checkout — a revoked invitation keeps it, which is what lets a reinstated reservation be restored onto the same invitation and audit history — and is released only when the invitation is deleted. Reconcile (below) converges instead of conflicting, so an adapter that always reconciles never has to handle that conflict at all. If one reservation legitimately needs several invitations (two guests, two arrival times), give each its own record_id — for example RES-4821#guest-2.

Also send X-Correlation-Id (or X-Request-Id) with your own event id. OpenApp stores it on the invitation, puts it on every audit event for the write, echoes it back in the X-Correlation-Id response header, and includes it in error bodies as correlationId.

POST /integrations/{id}/access-invites/reconcile takes a desired state and makes OpenApp match it. Use it for every booking event; you never have to look up or store OpenApp invitation ids, and there is no create-then-update race window.

Terminal window
# Check-in (or any change): make the invitation for this reservation match the desired state.
curl -sS -X POST \
-H "X-API-Key: ${OPENAPP_API_KEY}" \
-H "Content-Type: application/json" \
-H "X-Org: ${OPENAPP_ORG_ID}" \
-H "X-Correlation-Id: evt-8f21c0" \
-H "Idempotency-Key: evt-8f21c0" \
-d '{
"external_ref": { "source": "mews", "record_id": "RES-4821", "revision": 7 },
"state": "active",
"invitation": {
"portal_ids": ["'"${PORTAL_ID}"'"],
"name": "Room 214 - Ada Lovelace",
"schedules": [
{ "valid_from": "2026-06-01T15:00:00Z", "valid_to": "2026-06-05T11:00:00Z" }
],
"max_devices": 4
}
}' \
"${OPENAPP_API_BASE}/integrations/${INTEGRATION_ID}/access-invites/reconcile"
Terminal window
# Checkout or cancellation: no invitation body needed.
curl -sS -X POST \
-H "X-API-Key: ${OPENAPP_API_KEY}" \
-H "Content-Type: application/json" \
-H "X-Org: ${OPENAPP_ORG_ID}" \
-H "X-Correlation-Id: evt-91b7de" \
-d '{
"external_ref": { "source": "mews", "record_id": "RES-4821", "revision": 8 },
"state": "revoked"
}' \
"${OPENAPP_API_BASE}/integrations/${INTEGRATION_ID}/access-invites/reconcile"

The response reports what actually happened, so your adapter can log a real outcome instead of guessing:

actionMeaning
createdNo invitation existed for the record; one was created. The response carries invite_token and invite_url once
updatedThe existing invitation now matches the desired state
restoredThe invitation had been revoked and was restored, then updated (reinstated reservation)
revokedThe guest link no longer works; history and the external reference are preserved
unchangedThe desired state already held — a duplicate checkout, or a revoke for a record OpenApp never saw. The invitation is untouched, but the revision you sent is still recorded, so a later, older event is still rejected
SituationOpenApp behaviorWhat your adapter does
Same event delivered twiceReconcile converges (updated / unchanged); a create with the same Idempotency-Key replays the original responseNothing
Newer event applied first, older arrives later409 external_revision_stale; nothing is writtenDrop the event — the newer state already holds
Two events for the same new reservation raceOne creates, the other converges onto the same invitationNothing
Create response lost, event retriedSame Idempotency-Key replays the original result — no second invitationReuse the key on retries
Reservation reinstated after checkoutrestored reuses the same invitation row and audit historyNothing
Reinstating a reservation with a request OpenApp rejectsThe invitation stays revoked — a failed reconcile never leaves a guest link half-openFix the request and retry

Send revision on every write, including revokes. It is the only thing that lets OpenApp tell a late checkout from a current one.

Send Idempotency-Key on create and reconcile. The key is scoped to your organization and to the invitation API, and it is bound to the integration, the operation, and the request body.

CaseResult
First requestExecutes normally; the response is cached
Retry, same key and body, first call finished200 with the original body, plus idempotent_replay: true. invite_token / invite_url are omitted
Retry while the first call is still running409 idempotency_key_in_flight — retryable, wait and retry
Same key, different body409 idempotency_key_conflict — choose a new key
Request failedThe key is released, so you can fix the request and retry with the same key

Replay retention is 24 hours. PUT (update) and revoke are desired-state operations and are naturally idempotent: replaying them leaves the same state, and revoking an already revoked invitation succeeds with the original revoked_at.

Every invitation error carries a stable error_code next to the human-readable message — including a body that does not match the schema, where message names the offending field. Branch on the code; never parse the message. The one exception is a missing or invalid X-Org header: that is rejected as a plain 400 before the request reaches the invitation API at all.

error_codeStatusRetry
invitation_validation_failed400No — fix the request
invitation_limit_exceeded400No — a documented field limit was exceeded
invitation_contact_phone_region_required400No — send contacts[].phone_region, or send the number in E.164
invitation_revoked400No — restore the invitation first
invitation_unauthorized401No — the credential is missing or unusable
invitation_forbidden403No — the credential may not author here
invitation_policy_denied403No — an organization policy blocked or gated the write
invitation_active_limit_reached403No — an active-invitation cap applies
integration_not_found404No
invitation_not_found404No
invitation_portal_not_found404No — unknown portal, or one outside this integration/organization
invitation_location_not_found404No — unknown location_id, or one outside this organization tree
idempotency_key_conflict409No — the key was used with a different body
idempotency_key_in_flight409Yes — the first call is still running
external_reference_conflict409No — reconcile the existing invitation instead
external_revision_stale409No — the event is superseded; drop it
invitation_rate_limited429Yes — after Retry-After
quota_exceeded429Only after Retry-After (the next UTC day or month), or after a plan upgrade
invitation_internal_error500Yes — with the same Idempotency-Key; the write’s outcome is unknown

Every invitation response carries the current budget:

HeaderMeaning
X-RateLimit-LimitRequests allowed in the current window
X-RateLimit-RemainingRequests left in this window
X-RateLimit-ResetUnix seconds at which the window resets
Retry-AfterOn 429 only: seconds to wait before retrying
X-Correlation-IdYour request id, echoed for support

Three budgets apply to every invitation request. Reads (list, lookup) count too.

BudgetLimitCounted against
Per credential120 requests per minuteThe API key, agent, or user that authenticated, across every organization it acts in
Per organization600 requests per minuteThe organization that owns the integration or household, across all its credentials
Per customerYour plan’s invitation_api_requests quota, per UTC day and per UTC monthYour top-level organization, across all its sub-organizations

The organization is the one that owns the resource in the request path, not the X-Org header, so sending a different X-Org does not move a request to another budget. A request that is not authorized for the resource is refused before it counts against that organization or customer. Send X-Org once: a request that carries it twice is rejected with 400.

The per-minute budgets return invitation_rate_limited and are advertised in X-RateLimit-*. The customer quota returns quota_exceeded with Retry-After set to the start of the next UTC day or month; the quotas guide lists the limit for each plan.

There is no bulk endpoint: a batch that partially fails is harder to reason about than a sequence of idempotent calls, and reconcile already makes each call safe to repeat. Batch on your side:

  • Reconcile at most ~2 requests per second per credential to stay inside the budget with headroom.
  • Obey Retry-After on 429 and retry with the same Idempotency-Key; nothing is lost.
  • Use exponential backoff with jitter for 429 and 500, and stop retrying the codes marked “No” above.
  • After downtime, drive the recovery loop from your records, oldest event first, so revisions are applied in order.
FieldLimit
portal_ids25 portals per invitation
schedules32 entries
name120 characters
invitee_message8 KiB serialized
creation_justification500 characters
external_ref.source64 characters
external_ref.record_id128 characters
Idempotency-Key200 characters
X-Correlation-Id128 characters
  • invite_token and invite_url are returned once, by the call that creates the invitation. OpenApp stores only a hash.
  • Idempotent replays and every other route omit them.
  • Lost the link? POST /integrations/{id}/access-invites/{invite_link_id}/regenerate-token mints a new one — the previous link stops working immediately, including any link already delivered to a guest.
  • Treat both as bearer credentials: never write them to application logs, ticket systems, or analytics. OpenApp keeps them out of its own logs, audit details, and idempotency cache.
  • Send UTC RFC 3339 timestamps (2026-06-01T15:00:00Z). Offsets are accepted and normalized to UTC; the stored window is absolute, so it does not shift across DST.
  • Invitation curfews are evaluated in the organization’s IANA timezone. Create and preview responses return that timezone plus the effective curfew_windows, so you can explain a refused opening to your own users.
  • Recurring windows are UTC-anchored: an occurrence keeps its UTC time of day, so across a DST transition it falls an hour earlier or later in local time. Send an updated window when the local time is what matters.
  • Recurrence is bounded: a recurring window may not exceed the gap to the next occurrence.
  • An invitation whose window has passed is expired, not deleted: you can still update it (for example to extend the stay) and revoke it. Only a revoked invitation must be restored before it can be updated again.

Lookup and reconciliation never require OpenApp ids:

Terminal window
# One reservation.
curl -sS -H "X-API-Key: ${OPENAPP_API_KEY}" -H "X-Org: ${OPENAPP_ORG_ID}" \
"${OPENAPP_API_BASE}/integrations/${INTEGRATION_ID}/access-invites?external_source=mews&external_record_id=RES-4821"
# Everything this system owns, then page through it.
curl -sS -H "X-API-Key: ${OPENAPP_API_KEY}" -H "X-Org: ${OPENAPP_ORG_ID}" \
"${OPENAPP_API_BASE}/integrations/${INTEGRATION_ID}/access-invites?external_source=mews&state=active&limit=100"

List filters: external_source, external_record_id (requires external_source), state (active, scheduled, expired, disabled, revoked), portal_id, valid_from_after, valid_to_before, kind, plus limit / offset. Each item echoes its external_ref, so a full sweep rebuilds your mapping table.

POST /integrations/{id}/access-invites/preview runs exactly the validation, authorization, and policy evaluation that create runs, then stops before writing. It returns the effective schedule, the portals that would be granted, unlinked_portal_ids (portals with no live door device), required_identity_fields (pin, invitee_phone, photo), requires_justification, pending_approval, the organization timezone, and the effective curfew windows. A request that preview rejects is rejected by create with the same status, code, and message.

Use it during onboarding and whenever an operator changes portal or policy configuration.

Every invitation write is audited as access_invite.created, .updated, .revoked, .restored, .token_rotated, or .deleted, with:

  • the API key or agent that performed it (and the owning user as the RBAC principal),
  • the integration it was written through,
  • external_source, external_record_id, and external_revision,
  • your correlation_id.

A guest is never recorded as a trusted OpenApp user, and guest tokens never appear in audit details. Query the events with GET /orgs/{org_id}/audit/events — see Query access and audit activity.

Creating an invitation gives you a guest URL; it does not deliver it. OpenApp makes no delivery guarantee unless you explicitly use a delivery path (for example organization invitations over SMS). Sending the link through your PMS messaging, email, SMS, or WhatsApp — and retrying delivery — is your adapter’s responsibility.

The handler below is the whole contract in practice: one reconcile per event, the event id as both correlation id and idempotency key, and a retry policy driven by error_code.

import os
import random
import time
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"]
RETRYABLE = {"invitation_rate_limited", "idempotency_key_in_flight", "invitation_internal_error"}
SETTLED = {"external_revision_stale"}
def reconcile(event: dict) -> dict | None:
"""Apply one booking event. Returns the reconcile result, or None when superseded."""
body = {
"external_ref": {
"source": "mews",
"record_id": event["reservation_id"],
# The PMS revision, not a local counter: it is the ordering guard.
"revision": event["revision"],
},
"state": "revoked" if event["type"] in {"checkout", "cancelled"} else "active",
}
if body["state"] == "active":
body["invitation"] = {
"portal_ids": [LOBBY_PORTAL_ID],
"name": f"{event['room']} - {event['guest_name']}",
"schedules": [
{
"valid_from": event["arrival_utc"],
"valid_to": event["departure_utc"],
}
],
"max_devices": event.get("occupancy", 2),
}
headers = {
"x-api-key": API_KEY,
"content-type": "application/json",
"x-org": ORG,
# The event id: retries must reuse it so a lost response cannot double-create.
"idempotency-key": event["id"],
"x-correlation-id": event["id"],
}
url = f"{API_BASE}/integrations/{INTEGRATION_ID}/access-invites/reconcile"
for attempt in range(5):
with httpx.Client(timeout=30) as http:
response = http.post(url, headers=headers, json=body)
if response.is_success:
result = response.json()
# invite_token is present only when action == "created"; deliver it, do not log it.
return result
code = response.json().get("error_code", "")
if code in SETTLED:
return None # A newer revision already applied; drop this event.
if code not in RETRYABLE:
raise RuntimeError(f"reconcile rejected: {code} {response.text}")
delay = float(response.headers.get("retry-after", 2**attempt))
time.sleep(delay + random.uniform(0, 0.5))
raise RuntimeError("reconcile exhausted retries")