Skip to content
OpenAppPhysical access, fully managed
Login

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”
CapabilityOpenApp (native today)Your integration layer
Human audit reviewDashboard Activity page—
Machine-readable audit pull APIGET /orgs/{org_id}/audit/events (last 30 days, cursor paginated)Ingest into your SIEM / data warehouse
Historical exportPOST /orgs/{org_id}/audit/exports (any range) → signed downloadSchedule periodic exports
Outbound audit webhooksPOST /orgs/{org_id}/webhooks — HMAC-signed delivery of every eventVerify X-OpenApp-Signature, dedupe on X-OpenApp-Event-Id
Guest link usageGET /integrations/{id}/access-invites/{invite_link_id}/usage and counters on the invite—
Failed API tracecorrelationId on ApiErrorResponseTie agent logs to server traces
Inbound PMS eventsPMS connector: POST /pms/{connector_id}/inbound records pms.reservation.* eventsOr reconcile invitations from your own service

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.

TierWhat it holdsRetentionNotes
Hot window (queryable)Recent events in Postgres, served by the dashboard Activity page and GET /orgs/{org_id}/audit/events30 daysOlder ranges are rejected by the list API (audit_range_exceeds_hot_window) — use an export job
Long-term archiveEvery 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 downloadsGenerated export files (jsonl/csv) from an export job7 days after the job completes, then deletedThe 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 the audit:read / audit:webhooks:manage permission 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.

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.

Terminal window
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:

ParamNotes
event_typeExact match, e.g. entity.action.denied
outcomesucceeded | failed | denied
resource_type, resource_idExact resource identity; use resource_name for substring matching by snapshotted name
door_nameCase-insensitive substring match on the snapshotted destination name
target_idExact match on the snapshotted portal ID (details.public_portal_id)
actor_user_id, actor_kindPer-actor activity
actor_guest_idOne anonymous guest’s per-device fingerprint
org_scopedescendants (default) includes events in descendant orgs; current returns only this org’s events
correlation_idTie an event to a failed API call (see below)
occurred_after, occurred_beforeRFC 3339. The scan never reaches past the 30-day hot window; an occurred_after older than that is rejected
cursorPass the previous page’s next_cursor to fetch the next (older) page
limitPage 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.

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: guest with 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, or external.access), no org_id, resource, or correlation id, and details of { "action": …, "external": true }.

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:

Terminal window
curl -sS \
-H "X-API-Key: ${OPENAPP_API_KEY}" \
"${OPENAPP_API_BASE}/orgs/${OPENAPP_ORG_ID}/audit/events?correlation_id=01HCORREL00000000000000000"

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.

FieldTypeNotes
idstring (ULID)Globally unique; also the webhook idempotency key
occurred_atstring (RFC 3339)When the event happened (UTC)
org_idstring (ULID)Organization the event belongs to
actor_kindstringuser | guest | agent | system
actor_user_idstring (ULID), optionalThe user who acted; for agent, the agent’s owner
actor_guest_idstring (ULID), optionalAnonymous guest’s per-device fingerprint (never a user id)
actor_api_key_idstring (ULID), optionalPresent when the user acted through an API key (actor_kind stays user)
actor_agent_idstring (ULID), optionalPresent when actor_kind is agent
event_typestringDot-notation type, e.g. entity.action.succeeded
outcomestringsucceeded | failed | denied
resource_typestring, optionalentity | device | integration | user | …
resource_idstring (ULID), optionalThe affected resource
sourcestringapi | dashboard | public_access | smarthome | scripting | system
correlation_idstring (ULID), optionalTies the event to a request trace / API error
detailsobjectEvent-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.

The taxonomy grows over time. These are the families emitted today:

Familyevent_type valuesEmitted when
Entity actionsentity.action.succeeded, entity.action.deniedAny 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
Portalsaccess_portal.opened, access_portal.lights, access_portal.created / updated / deleted / restoredA portal opens a door or switches lights, or a portal changes
Invitationsaccess_invite.executed, access_invite.created / updated / revoked / deleted / restored / token_rotated / expiredA guest uses an invite (outcome shows success or failure), or an invite changes
Holdsdevice.hold.set / cleared / expired, entity.hold.set / cleared / expiredA hold open / hold closed is set, cleared, or runs out
Resourcesorg.*, user.*, integration.*, device.*, entity.*, zone.* (created, updated, deleted, restored, purged); integration.transferred; device.life_safety_attestation.recordedResource lifecycle changes
Policiespolicy.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 credentialsrole.granted, role.revoked, api_key.created / updated / revoked / restored / purgedRoles or API keys change
Agentsagent.created / updated / deleted, agent.skill.ranAgents change, or a hosted skill runs
Webhookswebhook.created / updated / deletedWebhook endpoints change (webhook.test is only sent to your endpoint, never stored)
Scriptingscripting.execution.started, scripting.execution.succeeded, scripting.execution.failedAn OpenApp Scripting execution starts or ends
People and groupssite_person.*, directory_listing_member.*, group.*, household.*, household_invitation.*, org_invitation.*Site people, listing members, groups, and households change
PMS connectorpms.reservation.checkout, pms.reservation.eventA PMS connector receives an inbound event
Othereula.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):

Terminal window
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):

Terminal window
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.

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.

Terminal window
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)"
}
ActionRequest
ListGET /orgs/{org_id}/webhooks
Update / pausePATCH /orgs/{org_id}/webhooks/{id} — change url, event_types, description, or enabled
DeleteDELETE /orgs/{org_id}/webhooks/{id}
Send a test deliveryPOST /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:

Terminal window
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 }

Each delivery is an HTTP POST whose JSON body is a single audit event, with these headers:

HeaderValue
Content-Typeapplication/json
X-OpenApp-Event-IdAudit event ULID — dedupe on this
X-OpenApp-Event-Typee.g. entity.action.denied
X-OpenApp-Signaturesha256=<hex> — HMAC-SHA256 of the raw request body using your signing secret
User-AgentOpenApp-Webhooks/1

Recompute the HMAC over the raw (unparsed) body and compare in constant time. Reject any request that does not match.

import hashlib
import 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),
);
}
  • At-least-once: deliveries are retried on network errors, timeouts, or any non-2xx response, then dead-lettered after repeated failures. Your endpoint must dedupe on X-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 / id if you need a stable sequence.
  • Acknowledge fast: return 2xx quickly and process asynchronously; slow handlers cause timeouts and retries.

See Errors & retries for the API error body shape.

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"])

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)

Terminal window
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)

On failure, parse JSON for correlationId — see Errors & retries.

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 '{}' \
"${OPENAPP_API_BASE}/entities/${ENTITY_ID}/actions/switchable.open"
  • Office / campus: central admins review dashboard audit; agents should not replace formal access reviews.
  • Hotel: on checkout, reconcile the reservation to state: revoked from your PMS integration. Revocation keeps the invitation and its history, and is reversible.

← Home Assistant bridge · Release a locker compartment →