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.
What you need
Section titled “What you need”| Value | Source |
|---|---|
| API key | Create an API key |
Organization id (X-Org) | Dashboard or GET /orgs |
| Virtual Access integration id | Dashboard → 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.
Address invitations by your own record id
Section titled “Address invitations by your own record id”Send external_ref with every write. It is a constrained model, not a metadata blob:
| Field | Meaning | Rules |
|---|---|---|
source | The 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_id | The record inside that system (reservation, enrolment, work order) | Opaque to OpenApp; printable, 1–128 chars |
revision | That record’s monotonic version | Integer ≥ 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.
Reconcile: one call per event
Section titled “Reconcile: one call per event”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.
# 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"# 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:
action | Meaning |
|---|---|
created | No invitation existed for the record; one was created. The response carries invite_token and invite_url once |
updated | The existing invitation now matches the desired state |
restored | The invitation had been revoked and was restored, then updated (reinstated reservation) |
revoked | The guest link no longer works; history and the external reference are preserved |
unchanged | The 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 |
Duplicate and out-of-order delivery
Section titled “Duplicate and out-of-order delivery”| Situation | OpenApp behavior | What your adapter does |
|---|---|---|
| Same event delivered twice | Reconcile converges (updated / unchanged); a create with the same Idempotency-Key replays the original response | Nothing |
| Newer event applied first, older arrives later | 409 external_revision_stale; nothing is written | Drop the event — the newer state already holds |
| Two events for the same new reservation race | One creates, the other converges onto the same invitation | Nothing |
| Create response lost, event retried | Same Idempotency-Key replays the original result — no second invitation | Reuse the key on retries |
| Reservation reinstated after checkout | restored reuses the same invitation row and audit history | Nothing |
| Reinstating a reservation with a request OpenApp rejects | The invitation stays revoked — a failed reconcile never leaves a guest link half-open | Fix 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.
Idempotency contract
Section titled “Idempotency contract”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.
| Case | Result |
|---|---|
| First request | Executes normally; the response is cached |
| Retry, same key and body, first call finished | 200 with the original body, plus idempotent_replay: true. invite_token / invite_url are omitted |
| Retry while the first call is still running | 409 idempotency_key_in_flight — retryable, wait and retry |
| Same key, different body | 409 idempotency_key_conflict — choose a new key |
| Request failed | The 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.
Error codes and retry classification
Section titled “Error codes and retry classification”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_code | Status | Retry |
|---|---|---|
invitation_validation_failed | 400 | No — fix the request |
invitation_limit_exceeded | 400 | No — a documented field limit was exceeded |
invitation_contact_phone_region_required | 400 | No — send contacts[].phone_region, or send the number in E.164 |
invitation_revoked | 400 | No — restore the invitation first |
invitation_unauthorized | 401 | No — the credential is missing or unusable |
invitation_forbidden | 403 | No — the credential may not author here |
invitation_policy_denied | 403 | No — an organization policy blocked or gated the write |
invitation_active_limit_reached | 403 | No — an active-invitation cap applies |
integration_not_found | 404 | No |
invitation_not_found | 404 | No |
invitation_portal_not_found | 404 | No — unknown portal, or one outside this integration/organization |
invitation_location_not_found | 404 | No — unknown location_id, or one outside this organization tree |
idempotency_key_conflict | 409 | No — the key was used with a different body |
idempotency_key_in_flight | 409 | Yes — the first call is still running |
external_reference_conflict | 409 | No — reconcile the existing invitation instead |
external_revision_stale | 409 | No — the event is superseded; drop it |
invitation_rate_limited | 429 | Yes — after Retry-After |
quota_exceeded | 429 | Only after Retry-After (the next UTC day or month), or after a plan upgrade |
invitation_internal_error | 500 | Yes — with the same Idempotency-Key; the write’s outcome is unknown |
Rate limits, retry headers, and batching
Section titled “Rate limits, retry headers, and batching”Every invitation response carries the current budget:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed in the current window |
X-RateLimit-Remaining | Requests left in this window |
X-RateLimit-Reset | Unix seconds at which the window resets |
Retry-After | On 429 only: seconds to wait before retrying |
X-Correlation-Id | Your request id, echoed for support |
Three budgets apply to every invitation request. Reads (list, lookup) count too.
| Budget | Limit | Counted against |
|---|---|---|
| Per credential | 120 requests per minute | The API key, agent, or user that authenticated, across every organization it acts in |
| Per organization | 600 requests per minute | The organization that owns the integration or household, across all its credentials |
| Per customer | Your plan’s invitation_api_requests quota, per UTC day and per UTC month | Your 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-Afteron429and retry with the sameIdempotency-Key; nothing is lost. - Use exponential backoff with jitter for
429and500, 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.
Request limits
Section titled “Request limits”| Field | Limit |
|---|---|
portal_ids | 25 portals per invitation |
schedules | 32 entries |
name | 120 characters |
invitee_message | 8 KiB serialized |
creation_justification | 500 characters |
external_ref.source | 64 characters |
external_ref.record_id | 128 characters |
Idempotency-Key | 200 characters |
X-Correlation-Id | 128 characters |
Guest tokens and URLs
Section titled “Guest tokens and URLs”invite_tokenandinvite_urlare 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-tokenmints 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.
Time and timezone rules
Section titled “Time and timezone rules”- 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
timezoneplus the effectivecurfew_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.
Recover without your mapping database
Section titled “Recover without your mapping database”Lookup and reconciliation never require OpenApp ids:
# 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.
Validate before creating physical access
Section titled “Validate before creating physical access”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.
Audit attribution
Section titled “Audit attribution”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, andexternal_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.
Delivery is not creation
Section titled “Delivery is not creation”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.
Reference adapter loop (Python)
Section titled “Reference adapter loop (Python)”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 osimport randomimport 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")Related docs
Section titled “Related docs”- Create a time-bound guest invitation — windows, portals, device and use limits.
- Integrate existing software — where OpenApp fits next to a PMS, SIS, or IWMS.
- Query access and audit activity — audit pull API, exports, and webhooks.
- Boutique hotel access playbook — a full hospitality rollout.
- OpenAPI bundle — codegen and contract tests.