Skip to content
OAOpenAppPhysical Security as a Service
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 usageInvite counters / validity on access invites
Failed API tracecorrelationId on ApiErrorResponseTie agent logs to server traces
Inbound PMS webhooksDocumented pattern — your service receives booking eventsCall OpenApp invite APIs

OpenApp commits to the following retention for audit data. These are the values the platform is configured to enforce today; treat them as the baseline retention SLA for the audit log.

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.
  • 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).

Terminal window
export OPENAPP_API_BASE='https://api.openapp.house/api/v1'
export OPENAPP_API_KEY='v1_openapp_YOUR_SECRET'
export OPENAPP_ORG_ID='01HORG00000000000000000000'
curl -sS \
-H "Authorization: Bearer ${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": "user",
"actor_user_id": "01HUSER0000000000000000000",
"event_type": "entity.action.denied",
"outcome": "denied",
"resource_type": "device",
"resource_id": "01HDEVICE000000000000000000",
"source": "public_access",
"details": { "action": "open" }
}
],
"next_cursor": "MjAyNi0wNi0yMVQxODozMDoxMlp8MDFKWjJOOE0..."
}

Query parameters:

ParamNotes
event_typeExact match, e.g. entity.action.denied
outcomesucceeded | failed | denied
resource_type, resource_id”show everything for this door/invite”
actor_user_id, actor_kindPer-actor activity
correlation_idTie an event to a failed API call (see below)
occurred_after, occurred_beforeRFC 3339; must fall inside the 30-day hot window
cursorPass the previous page’s next_cursor to fetch the next (older) page
limitPage size, default 50, max 200

For ranges older than 30 days the list endpoint returns 400 with error_code: audit_range_exceeds_hot_window — use an export job instead.

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 "Authorization: Bearer ${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 | api_key | guest | system | integration | webhook
actor_user_idstring (ULID), optionalPresent when a user performed the action
actor_api_key_idstring (ULID), optionalPresent when an API key performed the action
event_typestringDot-notation type, e.g. entity.action.succeeded
outcomestringsucceeded | failed | denied
resource_typestring, optionaldevice | integration | invite | 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 expands over time; the events emitted today are:

event_typeEmitted when
entity.action.succeededA device/entity action (e.g. door open) succeeds
entity.action.deniedA device/entity action is blocked by permissions
access_invite.executedA guest access invite is used (success or failure in outcome)
integration.createdAn integration is created
integration.deletedAn integration is deleted
role.grantedA role is granted to a user
role.revokedA role is revoked from a user
api_key.createdAn API key is created
api_key.revokedAn API key is revoked
palgate.credentials.relinkedPalGate integration credentials are re-linked
palgate.non_admin_acknowledgmentA non-admin acknowledges PalGate linking terms

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.

Create a job (defaults: full retention range, jsonl format — csv is also supported):

Terminal window
curl -sS -X POST \
-H "Authorization: Bearer ${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 pendingrunningcompleted (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 "Authorization: Bearer ${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 "Authorization: Bearer ${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 "Authorization: Bearer ${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.

List or fetch invites on the Virtual Access integration to see how often a guest link was used:

SDK

# No dedicated get helper — PUT with no changes also returns current payload.
edited = await client.integrations.update_access_invite(
integration_id,
invite_link_id,
is_enabled=True,
)
# Inspect returned payload for usage / validity in your deployment.

Response fields include usage counters and validity windows — see Integrations — access invites.

HTTP API (curl)

Terminal window
export OPENAPP_API_BASE='https://api.openapp.house/api/v1'
export OPENAPP_API_KEY='v1_openapp_YOUR_SECRET'
export OPENAPP_ORG_ID='01HORG00000000000000000000'
export INTEGRATION_ID='01HINTEGRATION00000000000000'
export INVITE_LINK_ID='01HINVITE000000000000000000'
curl -sS \
-H "Authorization: Bearer ${OPENAPP_API_KEY}" \
-H "X-Org: ${OPENAPP_ORG_ID}" \
"${OPENAPP_API_BASE}/integrations/${INTEGRATION_ID}/access-invites/${INVITE_LINK_ID}"

Log physical actions from your integration

Section titled “Log physical actions from your integration”

When your PMS or agent calls switchable.open, record the entity id, org id, and timestamp in your system of record:

SDK

from openapp_sdk.errors import ApiError
try:
await client.entities.by_id(entity_id).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 "Authorization: Bearer ${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: pair PMS checkout webhooks with invite disable — time-bound guest invitation.

← Home Assistant bridge · Release a locker compartment →