Query access and audit activity
Access audit answers “who opened what, when” for offices, hotels, and campuses. OpenApp records operational activity in a durable audit log that you can review in the dashboard Activity page, pull via the audit events API, export for any date range, or receive in real time through outbound webhooks.
This guide is for operators and integrators building compliance workflows. The audit log keeps a 30-day hot window that is queryable through the API; older history is retained in long-term storage and retrieved with bulk export jobs.
What OpenApp provides natively vs your integrator
Section titled “What OpenApp provides natively vs your integrator”| Capability | OpenApp (native today) | Your integration layer |
|---|---|---|
| Human audit review | Dashboard Activity page | — |
| Machine-readable audit pull API | GET /orgs/{org_id}/audit/events (last 30 days, cursor paginated) | Ingest into your SIEM / data warehouse |
| Historical export | POST /orgs/{org_id}/audit/exports (any range) → signed download | Schedule periodic exports |
| Outbound audit webhooks | POST /orgs/{org_id}/webhooks — HMAC-signed delivery of every event | Verify X-OpenApp-Signature, dedupe on X-OpenApp-Event-Id |
| Guest link usage | GET /integrations/{id}/access-invites/{invite_link_id}/usage and counters on the invite | — |
| Failed API trace | correlationId on ApiErrorResponse | Tie agent logs to server traces |
| Inbound PMS events | PMS connector: POST /pms/{connector_id}/inbound records pms.reservation.* events | Or reconcile invitations from your own service |
Data retention
Section titled “Data retention”Audit retention is fixed by the platform’s infrastructure; there is no per-organization retention setting. These are the values the platform is configured to enforce today.
| Tier | What it holds | Retention | Notes |
|---|---|---|---|
| Hot window (queryable) | Recent events in Postgres, served by the dashboard Activity page and GET /orgs/{org_id}/audit/events | 30 days | Older ranges are rejected by the list API (audit_range_exceeds_hot_window) — use an export job |
| Long-term archive | Every event, written through to object storage (S3) | 7 years (≈2557 days) | Encrypted at rest (AES-256) and versioned; transitions to cold storage (Glacier) after 1 year |
| Export downloads | Generated export files (jsonl/csv) from an export job | 7 days after the job completes, then deleted | The download_url presigned link itself is valid for ~1 hour — re-fetch the job to mint a fresh link |
Additional guarantees:
- Completeness: every event lands in the long-term archive before its Postgres partition ages out of the hot window, so there is no gap between the two tiers.
- Tenant isolation: audit data is partitioned per organization (
org_id). A caller can only read events, exports, and webhook config for an org they hold theaudit:read/audit:webhooks:managepermission in. See Who sees what for events that involve two organizations. - Immutability: audit events are append-only; there is no API to edit or delete an individual event within the retention window.
If your compliance program requires a copy beyond 7 years or in your own system of record, keep a continuous copy via outbound webhooks or scheduled export jobs.
List audit events (pull API)
Section titled “List audit events (pull API)”GET /orgs/{org_id}/audit/events returns the last 30 days, newest first, with cursor pagination and filters. Requires the audit:read permission (granted to org admins by default). By default the list covers the org and its descendant orgs.
export OPENAPP_API_BASE='https://openapp.house/api/v1'export OPENAPP_API_KEY='https://openapp.house_openapp_YOUR_SECRET'export OPENAPP_ORG_ID='01HORG00000000000000000000'
curl -sS \ -H "X-API-Key: ${OPENAPP_API_KEY}" \ "${OPENAPP_API_BASE}/orgs/${OPENAPP_ORG_ID}/audit/events?outcome=denied&limit=50"The response is a page of events plus an opaque next_cursor (absent on the last page):
{ "items": [ { "id": "01JZ2N8M4P5Q6R7S8T9V0W1X2Y", "occurred_at": "2026-06-21T18:30:12.482Z", "org_id": "01HORG00000000000000000000", "actor_kind": "agent", "actor_user_id": "01HUSER0000000000000000000", "actor_agent_id": "01HAGENT000000000000000000", "event_type": "entity.action.denied", "outcome": "denied", "resource_type": "entity", "resource_id": "01HENTITY000000000000000000", "source": "api", "correlation_id": "01HCORREL00000000000000000", "details": { "action": "switchable.open", "status": 409, "reason": "This opening must be classified before it can be used (ingress vs egress vs fire door vs vehicle gate).", "error_code": "life_safety_class_required", "resource_name": "Front door" } } ], "next_cursor": "MjAyNi0wNi0yMVQxODozMDoxMlp8MDFKWjJOOE0..."}Query parameters:
| Param | Notes |
|---|---|
event_type | Exact match, e.g. entity.action.denied |
outcome | succeeded | failed | denied |
resource_type, resource_id | Exact resource identity; use resource_name for substring matching by snapshotted name |
door_name | Case-insensitive substring match on the snapshotted destination name |
target_id | Exact match on the snapshotted portal ID (details.public_portal_id) |
actor_user_id, actor_kind | Per-actor activity |
actor_guest_id | One anonymous guest’s per-device fingerprint |
org_scope | descendants (default) includes events in descendant orgs; current returns only this org’s events |
correlation_id | Tie an event to a failed API call (see below) |
occurred_after, occurred_before | RFC 3339. The scan never reaches past the 30-day hot window; an occurred_after older than that is rejected |
cursor | Pass the previous page’s next_cursor to fetch the next (older) page |
limit | Page size, default 50, max 200 |
An occurred_after older than 30 days returns 400 with error_code: audit_range_exceeds_hot_window — use an export job instead. occurred_before isn’t checked against the window.
Who sees what
Section titled “Who sees what”An event can involve two organizations: the org that owns the door, and an org the acting user belongs to. Each sees its own projection:
- The owning org sees the full event. When a public-access actor isn’t a member of the owning org, the event shows
actor_kind: guestwith no user or API-key id, so the owner can’t learn which other orgs that person belongs to. - The actor’s member org sees only that its member did something elsewhere: a generalized
event_type(external.door_opened,external.lights, orexternal.access), noorg_id, resource, or correlation id, anddetailsof{ "action": …, "external": true }.
Tie a failed request to its audit event
Section titled “Tie a failed request to its audit event”When an API call fails, the ApiErrorResponse body carries a server-generated correlationId. The same value is stored on the matching audit event’s correlation_id, so you can look up exactly what the server recorded for a failed operation:
curl -sS \ -H "X-API-Key: ${OPENAPP_API_KEY}" \ "${OPENAPP_API_BASE}/orgs/${OPENAPP_ORG_ID}/audit/events?correlation_id=01HCORREL00000000000000000"Audit event shape
Section titled “Audit event shape”Every audit event — in the pull API, an export file, and a webhook delivery — uses the same JSON shape. Optional fields are omitted when empty.
| Field | Type | Notes |
|---|---|---|
id | string (ULID) | Globally unique; also the webhook idempotency key |
occurred_at | string (RFC 3339) | When the event happened (UTC) |
org_id | string (ULID) | Organization the event belongs to |
actor_kind | string | user | guest | agent | system |
actor_user_id | string (ULID), optional | The user who acted; for agent, the agent’s owner |
actor_guest_id | string (ULID), optional | Anonymous guest’s per-device fingerprint (never a user id) |
actor_api_key_id | string (ULID), optional | Present when the user acted through an API key (actor_kind stays user) |
actor_agent_id | string (ULID), optional | Present when actor_kind is agent |
event_type | string | Dot-notation type, e.g. entity.action.succeeded |
outcome | string | succeeded | failed | denied |
resource_type | string, optional | entity | device | integration | user | … |
resource_id | string (ULID), optional | The affected resource |
source | string | api | dashboard | public_access | smarthome | scripting | system |
correlation_id | string (ULID), optional | Ties the event to a request trace / API error |
details | object | Event-specific payload (slim — ids and enums, not full snapshots) |
Outcome semantics: denied attempts (permission failures, blocked opens) are recorded as first-class events — filter on outcome=denied for security review.
Event types
Section titled “Event types”The taxonomy grows over time. These are the families emitted today:
| Family | event_type values | Emitted when |
|---|---|---|
| Entity actions | entity.action.succeeded, entity.action.denied | Any switchable.* action through the entity-action API (HTTP, hosted MCP, agents, voice keys). denied carries status, reason, and error_code in details. Previews and confirmation challenges aren’t recorded |
| Portals | access_portal.opened, access_portal.lights, access_portal.created / updated / deleted / restored | A portal opens a door or switches lights, or a portal changes |
| Invitations | access_invite.executed, access_invite.created / updated / revoked / deleted / restored / token_rotated / expired | A guest uses an invite (outcome shows success or failure), or an invite changes |
| Holds | device.hold.set / cleared / expired, entity.hold.set / cleared / expired | A hold open / hold closed is set, cleared, or runs out |
| Resources | org.*, user.*, integration.*, device.*, entity.*, zone.* (created, updated, deleted, restored, purged); integration.transferred; device.life_safety_attestation.recorded | Resource lifecycle changes |
| Policies | policy.emergency_free_egress.applied, policy.curfew.denied, policy.tailgating_cooldown.denied, policy.first_use, policy.new_sharing, policy.group_membership_change, policy.dormant.*, policy.moveout.* | A policy lifted or blocked an action, or a notify / lifecycle policy fired |
| Roles and credentials | role.granted, role.revoked, api_key.created / updated / revoked / restored / purged | Roles or API keys change |
| Agents | agent.created / updated / deleted, agent.skill.ran | Agents change, or a hosted skill runs |
| Webhooks | webhook.created / updated / deleted | Webhook endpoints change (webhook.test is only sent to your endpoint, never stored) |
| Scripting | scripting.execution.started, scripting.execution.succeeded, scripting.execution.failed | An OpenApp Scripting execution starts or ends |
| People and groups | site_person.*, directory_listing_member.*, group.*, household.*, household_invitation.*, org_invitation.* | Site people, listing members, groups, and households change |
| PMS connector | pms.reservation.checkout, pms.reservation.event | A PMS connector receives an inbound event |
| Other | eula.accepted, account_merge.*, call.blocked / unblocked / reported, store.*, palgate.* | Account, call, store, and PalGate linking events |
Filter by exact event_type; subscribe webhooks to the exact values you need, or omit event_types to receive everything.
Export historical data (older than 30 days)
Section titled “Export historical data (older than 30 days)”Postgres keeps only a 30-day hot window; the complete history (7+ years) lives in S3 and is reached with async export jobs. Exports read the archive directly, so they never compete with live traffic. In the dashboard, open Activity and choose Export to request the same job from the UI.
Create a job (defaults: full retention range, jsonl format — csv is also supported):
curl -sS -X POST \ -H "X-API-Key: ${OPENAPP_API_KEY}" \ -H "Content-Type: application/json" \ -d '{"occurred_after":"2026-01-01T00:00:00Z","occurred_before":"2026-03-01T00:00:00Z","format":"jsonl"}' \ "${OPENAPP_API_BASE}/orgs/${OPENAPP_ORG_ID}/audit/exports"The call returns 202 Accepted with a job whose status moves through pending → running → completed (or failed, with an error_message). Poll the job until it completes, then download from the presigned download_url (valid ~1 hour):
curl -sS \ -H "X-API-Key: ${OPENAPP_API_KEY}" \ "${OPENAPP_API_BASE}/orgs/${OPENAPP_ORG_ID}/audit/exports/${JOB_ID}"# { "id": "...", "status": "completed", "format": "jsonl", "row_count": 1832,# "download_url": "https://...s3...", "completed_at": "..." }List recent jobs with GET /orgs/{org_id}/audit/exports. All export endpoints require the audit:read permission.
Stream events to your endpoint (webhooks)
Section titled “Stream events to your endpoint (webhooks)”Register HTTPS endpoints that receive every audit event in real time. Delivery runs through an at-least-once pipeline (SNS → SQS → a dispatcher), so this is the recommended way for integrators to keep a continuous, multi-year copy of their audit history. Managing webhooks requires the audit:webhooks:manage permission; you can also manage them from Settings → Webhooks in the dashboard.
Register an endpoint
Section titled “Register an endpoint”curl -sS -X POST \ -H "X-API-Key: ${OPENAPP_API_KEY}" \ -H "Content-Type: application/json" \ -d '{"url":"https://example.com/openapp/webhook","event_types":["entity.action.denied","role.granted"]}' \ "${OPENAPP_API_BASE}/orgs/${OPENAPP_ORG_ID}/webhooks"The URL must be https. Omit event_types (or pass an empty list) to receive all events. The response includes a signing_secret returned exactly once — store it securely; it is used to verify deliveries and is never shown again.
{ "id": "01HWEBHOOK00000000000000000", "org_id": "01HORG00000000000000000000", "url": "https://example.com/openapp/webhook", "event_types": ["entity.action.denied", "role.granted"], "enabled": true, "signing_secret": "f3b1…(64 hex chars, shown once)"}Manage endpoints
Section titled “Manage endpoints”| Action | Request |
|---|---|
| List | GET /orgs/{org_id}/webhooks |
| Update / pause | PATCH /orgs/{org_id}/webhooks/{id} — change url, event_types, description, or enabled |
| Delete | DELETE /orgs/{org_id}/webhooks/{id} |
| Send a test delivery | POST /orgs/{org_id}/webhooks/{id}/test |
To rotate a signing secret, delete the endpoint and create a new one (there is no in-place rotation). The test endpoint sends a synthetic webhook.test event signed with the live secret so you can validate your receiver immediately:
curl -sS -X POST \ -H "X-API-Key: ${OPENAPP_API_KEY}" \ "${OPENAPP_API_BASE}/orgs/${OPENAPP_ORG_ID}/webhooks/${WEBHOOK_ID}/test"# { "delivered": true, "status_code": 200 }Delivery format
Section titled “Delivery format”Each delivery is an HTTP POST whose JSON body is a single audit event, with these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
X-OpenApp-Event-Id | Audit event ULID — dedupe on this |
X-OpenApp-Event-Type | e.g. entity.action.denied |
X-OpenApp-Signature | sha256=<hex> — HMAC-SHA256 of the raw request body using your signing secret |
User-Agent | OpenApp-Webhooks/1 |
Verify the signature
Section titled “Verify the signature”Recompute the HMAC over the raw (unparsed) body and compare in constant time. Reject any request that does not match.
import hashlibimport hmac
def verify(signing_secret: str, raw_body: bytes, signature_header: str) -> bool: expected = "sha256=" + hmac.new( signing_secret.encode(), raw_body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, signature_header)import crypto from "node:crypto";
function verify(signingSecret, rawBody, signatureHeader) { const expected = "sha256=" + crypto.createHmac("sha256", signingSecret).update(rawBody).digest("hex"); return crypto.timingSafeEqual( Buffer.from(expected), Buffer.from(signatureHeader), );}Reliability
Section titled “Reliability”- At-least-once: deliveries are retried on network errors, timeouts, or any non-
2xxresponse, then dead-lettered after repeated failures. Your endpoint must dedupe onX-OpenApp-Event-Id, since the same event can be re-POSTed. - Timeout: each delivery attempt allows ~10 seconds for your endpoint to respond.
- Ordering: not guaranteed — order by
occurred_at/idif you need a stable sequence. - Acknowledge fast: return
2xxquickly and process asynchronously; slow handlers cause timeouts and retries.
See Errors & retries for the API error body shape.
Invite usage (guest access)
Section titled “Invite usage (guest access)”Read who used one invitation, and which door they opened, newest first:
SDK
# No dedicated helper for the usage route yet; call it through the client transport.usage = await client._request( "GET", f"/integrations/{integration_id}/access-invites/{invite_link_id}/usage",)for entry in usage["entries"]: print(entry["occurred_at"], entry["door_name"], entry["outcome"])const usage = await fetch( `${apiBase}/integrations/${integrationId}/access-invites/${inviteLinkId}/usage`, { headers: { "x-api-key": apiKey, "x-org": orgId, }, },).then((r) => r.json() as Promise<{ entries: Array<{ occurred_at: string; door_name?: string; outcome: string }> }>);use openapp_sdk::{Client, transport::RequestSpec};use reqwest::Method;
let client = Client::builder() .api_key("https://openapp.house_openapp_YOUR_SECRET") .build()?;
let path = format!("/integrations/{integration_id}/access-invites/{invite_link_id}/usage");let usage: serde_json::Value = client .transport() .request_json::<(), _>(RequestSpec { method: Method::GET, path: &path, extra_headers: &[("X-Org", org_id.to_string())], ..Default::default() }) .await?;usage, httpResp, err := client.IntegrationsAPI.ListIntegrationAccessInviteUsage(ctx, integrationID, inviteLinkID). XOrg(orgID). Execute()if err != nil { return err}defer httpResp.Body.Close()for _, entry := range usage.GetEntries() { _ = entry}To find an invitation by your own booking id instead of its OpenApp id, list with external_source and external_record_id (GET /integrations/{id}/access-invites?external_source=…&external_record_id=…); the list also carries each invite’s uses and validity window.
HTTP API (curl)
export OPENAPP_API_BASE='https://openapp.house/api/v1'export OPENAPP_API_KEY='https://openapp.house_openapp_YOUR_SECRET'export OPENAPP_ORG_ID='01HORG00000000000000000000'export INTEGRATION_ID='01HINTEGRATION00000000000000'export INVITE_LINK_ID='01HINVITE000000000000000000'
curl -sS \ -H "X-API-Key: ${OPENAPP_API_KEY}" \ -H "X-Org: ${OPENAPP_ORG_ID}" \ "${OPENAPP_API_BASE}/integrations/${INTEGRATION_ID}/access-invites/${INVITE_LINK_ID}/usage"Log physical actions from your integration
Section titled “Log physical actions from your integration”OpenApp already records every switchable.open as entity.action.succeeded or entity.action.denied. When your PMS or agent calls it, keep the correlation id on failures so your logs join the audit event:
SDK
from openapp_sdk.errors import ApiError
try: await client.entities.actions(entity_id, "switchable.open")except ApiError as err: log_correlation(err.correlation_id, entity_id=entity_id)const res = await fetch( `${apiBase}/entities/${entityId}/actions/switchable.open`, { method: "POST", headers: { "x-api-key": apiKey, "content-type": "application/json", "x-org": orgId, }, body: JSON.stringify({}), },);if (!res.ok) { const body = (await res.json()) as { correlationId?: string }; logCorrelation(body.correlationId, { entityId });}use openapp_sdk::{Client, SdkError};use serde_json::json;
let client = Client::builder() .api_key("https://openapp.house_openapp_YOUR_SECRET") .build()?;
match client .entities() .invoke_action(entity_id, "switchable.open", &json!({})) .await{ Ok(_) => {} Err(SdkError::Api { body, .. }) => { log_correlation(body.correlation_id.as_deref(), entity_id); } Err(e) => return Err(e.into()),}_, httpResp, err := client.EntitiesAPI.ExecuteEntityAction(ctx, entityID, "switchable.open"). Body(map[string]interface{}{}). Execute()if err != nil { // Decode ApiErrorResponse from httpResp.Body for correlationId when present. return err}defer httpResp.Body.Close()On failure, parse JSON for correlationId — see Errors & retries.
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 '{}' \ "${OPENAPP_API_BASE}/entities/${ENTITY_ID}/actions/switchable.open"Sector notes
Section titled “Sector notes”- Office / campus: central admins review dashboard audit; agents should not replace formal access reviews.
- Hotel: on checkout, reconcile the reservation to
state: revokedfrom your PMS integration. Revocation keeps the invitation and its history, and is reversible.