Virtual intercom — directory, call, unlock
A virtual intercom replaces or augments a lobby panel: visitors scan a QR code or open a public portal URL, browse the building’s directory listings, call or message a listing, and the resident who answers can unlock the door. OpenApp implements this through Virtual Access integrations, access portals, and public session APIs — no rip-and-replace hardware required when you already have relays or cloud gate APIs.
This guide targets building operators and integrators automating apartment and office entry. For end-user portal concepts, see Access Portals and Virtual Access integration.
Flow overview
Section titled “Flow overview”Visitor scans portal QR (/p/{public_id}) │ ├─ Directory mode → pick listing → POST portal/sessions (voice / video / message) │ │ caller gets caller_token │ └─ Resident is notified with callee_token │ └─ Resident answers → POST session/open?token=<callee_token> │ └─ Guest with invite → quick open (invite execute)Setup (one-time)
Section titled “Setup (one-time)”- Add a Virtual Access integration and link a door device to a portal (portals user guide).
- Note
publicPortalIdfrom the portal’s public URL, and thetarget_entity_idof the directory listing the visitor calls (list them withGET /public/access/portals/{publicPortalId}/targets).
- Optional: go2rtc for live camera on video calls.
Fill the building directory from photos
Section titled “Fill the building directory from photos”If the physical intercom or mailbox bank already lists unit numbers and resident names but listing display names in OpenApp are empty, use Enrich (Virtual intercom → Enrich): upload panel photos, Analyze, review, and Commit. This fills the visitor directory labels visitors see when browsing the portal.
See Virtual Access — automatic directory filling from photos for privacy, API key setup, and manual JSON fallback.
Directory + call session
Section titled “Directory + call session”Start a session from the public portal. Body PublicPortalCreateSessionRequest requires target_entity_id (the listing) and mode: "voice", "video", or "message". Any other mode returns 400. Signed-in minors can’t place visitor calls (403).
SDK
session = await client.public_access.portal_start_session( portal_id, target_entity_id=entity_id, mode="voice", # voice | video | message)session_id = session["session_id"]caller_token = session["caller_token"]const session = await fetch( `${apiBase}/public/access/portals/${portalId}/sessions`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ target_entity_id: entityId, mode: "voice", }), },).then((r) => r.json() as Promise<{ session_id: string; caller_token: string }>);
const sessionId = session.session_id;const callerToken = session.caller_token;use openapp_sdk::Client;use serde_json::json;
let client = Client::builder() .api_key("https://openapp.house_openapp_YOUR_SECRET") .build()?;
let session = client .public_access() .portal_start_session( portal_id, &json!({ "target_entity_id": entity_id, "mode": "voice" }), ) .await?;let session_id = session["session_id"].as_str().unwrap_or_default();let caller_token = session["caller_token"].as_str().unwrap_or_default();body := *openapiclient.NewPublicPortalCreateSessionRequest("voice", entityID)resp, httpResp, err := client.PublicAccessAPI.PostPublicPortalSessions(ctx, portalID). PublicPortalCreateSessionRequest(body). Execute()if err != nil { return err}defer httpResp.Body.Close()sessionID := resp.GetSessionId()callerToken := resp.GetCallerToken()The response includes session_id, caller_token, expires_at, PeerJS routing, and callees_notified. Poll GET /public/access/sessions/{sessionId} with the caller token until the callee answers. In message mode no call is rung; residents are notified when a message is sent.
HTTP API (curl)
export OPENAPP_API_BASE='https://openapp.house/api/v1'export PUBLIC_PORTAL_ID='01HPUBLICPORTAL000000000000'export TARGET_ENTITY_ID='01HENTITY000000000000000000'
curl -sS -X POST \ -H "Content-Type: application/json" \ -d '{ "target_entity_id": "'"${TARGET_ENTITY_ID}"'", "mode": "voice" }' \ "${OPENAPP_API_BASE}/public/access/portals/${PUBLIC_PORTAL_ID}/sessions"Unlock after answer
Section titled “Unlock after answer”Only the callee opens the door. OpenApp never returns the callee token to the caller; it goes to the listing’s residents in the incoming-call notification (web push and the native app). The resident’s client sends it as Authorization: Bearer <callee_token> or ?token=<callee_token>. A caller token, or no token, is refused (403 / 401).
SDK
# session_open() can't send the callee token; call the route through the client transport.await client._request( "POST", f"/public/access/sessions/{session_id}/open", query=[("token", callee_token)],)await fetch( `${apiBase}/public/access/sessions/${sessionId}/open?token=${encodeURIComponent(calleeToken)}`, { method: "POST" },);use openapp_sdk::{Client, transport::RequestSpec};use reqwest::Method;
let client = Client::builder() .api_key("https://openapp.house_openapp_YOUR_SECRET") .build()?;
// session_open() can't send the callee token; call the route through the transport.let path = format!("/public/access/sessions/{session_id}/open");let _: serde_json::Value = client .transport() .request_json::<(), _>(RequestSpec { method: Method::POST, path: &path, query: &[("token", Some(callee_token.to_string()))], ..Default::default() }) .await?;httpResp, err := client.PublicAccessAPI.PostPublicSessionOpen(ctx, sessionID). Token(calleeToken). Execute()if err != nil { return err}defer httpResp.Body.Close()See Public Access.
HTTP API (curl)
export CALLEE_TOKEN='token-from-the-incoming-call-notification'
curl -sS -X POST \ -H "Authorization: Bearer ${CALLEE_TOKEN}" \ "${OPENAPP_API_BASE}/public/access/sessions/${SESSION_ID}/open"Residents receive calls through Web Push and the native app; configure subscriptions via Me — push. Session opens count against the door_opens quota and go through the same life-safety gate as any open.
Guest quick-open (invitation path)
Section titled “Guest quick-open (invitation path)”Visitors with a valid invite link skip the directory and use invite execute. Pair with Time-bound guest invitation for STR and hotel stays.
Agent automation tips
Section titled “Agent automation tips”| Step | API surface |
|---|---|
| List portals | GET /integrations/{id}/access-portals |
| Probe reachability | GET /public/access/portals/{id}/reachable |
| Callable targets | GET /public/access/portals/{id}/targets |
| Who opened | Portal opens are audited as access_portal.opened — see Query access and audit activity |
Full operation index: Agent-relevant API.