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 | Invite counters / validity on access invites | — |
| Failed API trace | correlationId on ApiErrorResponse | Tie agent logs to server traces |
| Inbound PMS webhooks | Documented pattern — your service receives booking events | Call OpenApp invite APIs |
Data retention
Section titled “Data retention”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.
| 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. - 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).
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:
| Param | Notes |
|---|---|
event_type | Exact match, e.g. entity.action.denied |
outcome | succeeded | failed | denied |
resource_type, resource_id | ”show everything for this door/invite” |
actor_user_id, actor_kind | Per-actor activity |
correlation_id | Tie an event to a failed API call (see below) |
occurred_after, occurred_before | RFC 3339; must fall inside the 30-day hot window |
cursor | Pass the previous page’s next_cursor to fetch the next (older) page |
limit | Page 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.
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 "Authorization: Bearer ${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 | api_key | guest | system | integration | webhook |
actor_user_id | string (ULID), optional | Present when a user performed the action |
actor_api_key_id | string (ULID), optional | Present when an API key performed the action |
event_type | string | Dot-notation type, e.g. entity.action.succeeded |
outcome | string | succeeded | failed | denied |
resource_type | string, optional | device | integration | invite | 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 expands over time; the events emitted today are:
event_type | Emitted when |
|---|---|
entity.action.succeeded | A device/entity action (e.g. door open) succeeds |
entity.action.denied | A device/entity action is blocked by permissions |
access_invite.executed | A guest access invite is used (success or failure in outcome) |
integration.created | An integration is created |
integration.deleted | An integration is deleted |
role.granted | A role is granted to a user |
role.revoked | A role is revoked from a user |
api_key.created | An API key is created |
api_key.revoked | An API key is revoked |
palgate.credentials.relinked | PalGate integration credentials are re-linked |
palgate.non_admin_acknowledgment | A 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):
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 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 "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.
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 "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)"}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 "Authorization: Bearer ${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 (proxy for guest access)
Section titled “Invite usage (proxy for guest access)”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.const invite = await fetch( `${apiBase}/integrations/${integrationId}/access-invites/${inviteLinkId}`, { headers: { authorization: `Bearer ${apiKey}`, "x-org": orgId, }, },).then((r) => r.json());use openapp_sdk::{Client, transport::RequestSpec};use reqwest::Method;
let client = Client::builder() .api_key("https://api.openapp.house/api/v1_openapp_YOUR_SECRET") .build()?;
let path = format!("/integrations/{integration_id}/access-invites/{invite_link_id}");let invite: serde_json::Value = client .transport() .request_json(RequestSpec { method: Method::GET, path: &path, extra_headers: &[("X-Org", org_id)], ..Default::default() }) .await?;patch := openapiclient.NewUpdateAccessInviteRequest()patch.SetIsEnabled(true)edited, httpResp, err := client.IntegrationsAPI.UpdateIntegrationAccessInvite(ctx, integrationID, inviteLinkID). XOrg(orgID). UpdateAccessInviteRequest(*patch). Execute()if err != nil { return err}defer httpResp.Body.Close()_ = editedResponse fields include usage counters and validity windows — see Integrations — access invites.
HTTP API (curl)
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)const res = await fetch( `${apiBase}/entities/${entityId}/actions/switchable.open`, { method: "POST", headers: { authorization: `Bearer ${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://api.openapp.house/api/v1_openapp_YOUR_SECRET") .build()?;
match client .entities() .invoke_action(entity_id, "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, "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 "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"Sector notes
Section titled “Sector notes”- 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.