diff --git a/package.json b/package.json
index 93154d2d912d0d40d3ff8a067326064c206d46b5..8108484371d2125a453d1a171a0c55b3a6d4deba 100644
--- a/package.json
+++ b/package.json
@@ -35,6 +35,7 @@
     "naf-janus-adapter": "https://github.com/mozilla/naf-janus-adapter#feature/disconnect",
     "networked-aframe": "https://github.com/mozillareality/networked-aframe#mr-social-client/master",
     "nipplejs": "^0.6.7",
+    "phoenix": "^1.3.0",
     "query-string": "^5.0.1",
     "raven-js": "^3.20.1",
     "react": "^16.1.1",
diff --git a/src/hub.js b/src/hub.js
index 0b34551cea7e804f2e0ab48d729dbcc1972d58f6..88b8190d26aabe982b24c45ea1beb7ce02306082 100644
--- a/src/hub.js
+++ b/src/hub.js
@@ -1,5 +1,7 @@
 import "./assets/stylesheets/hub.scss";
+import uuid from "uuid/v4";
 import queryString from "query-string";
+import { Socket } from "phoenix";
 
 import { patchWebGLRenderingContext } from "./utils/webgl";
 patchWebGLRenderingContext();
@@ -48,6 +50,7 @@ import "./components/gltf-bundle";
 import ReactDOM from "react-dom";
 import React from "react";
 import UIRoot from "./react-components/ui-root";
+import HubChannel from "./utils/hub-channel";
 
 import "./systems/personal-space-bubble";
 import "./systems/app-mode";
@@ -101,6 +104,7 @@ AFRAME.registerInputMappings(inputConfig, true);
 
 const store = new Store();
 const concurrentLoadDetector = new ConcurrentLoadDetector();
+const hubChannel = new HubChannel(store);
 
 concurrentLoadDetector.start();
 
@@ -108,6 +112,10 @@ concurrentLoadDetector.start();
 store.update({ profile: { ...generateDefaultProfile(), ...(store.state.profile || {}) } });
 
 async function exitScene() {
+  if (hubChannel) {
+    hubChannel.disconnect();
+  }
+
   const scene = document.querySelector("a-scene");
   scene.renderer.animate(null); // Stop animation loop, TODO A-Frame should do this
   document.body.removeChild(scene);
@@ -179,9 +187,7 @@ async function enterScene(mediaStream, enterInVR, janusRoomId) {
     screenEntity.setAttribute("visible", sharingScreen);
   });
 
-  if (qsTruthy("offline")) {
-    onConnect();
-  } else {
+  if (!qsTruthy("offline")) {
     document.body.addEventListener("connected", onConnect);
 
     scene.components["networked-scene"].connect();
@@ -207,7 +213,10 @@ async function enterScene(mediaStream, enterInVR, janusRoomId) {
   }
 }
 
-function onConnect() {}
+function onConnect() {
+  hubChannel.sendEntryEvent();
+  store.update({ lastEnteredAt: new Date().toString() });
+}
 
 function mountUI(scene) {
   const disableAutoExitOnConcurrentLoad = qsTruthy("allow_multi");
@@ -270,15 +279,32 @@ const onReady = async () => {
     return;
   }
 
-  const hubId = document.location.pathname.substring(1).split("/")[0];
+  // Connect to reticulum over phoenix channels to get hub info.
+  const hubId = qs.hub_id || document.location.pathname.substring(1).split("/")[0];
   console.log(`Hub ID: ${hubId}`);
-  const res = await fetch(`/api/v1/hubs/${hubId}`);
-  const data = await res.json();
-  const hub = data.hubs[0];
-  const defaultSpaceTopic = hub.topics[0];
-  const gltfBundleUrl = defaultSpaceTopic.assets.find(a => a.asset_type === "gltf_bundle").src;
-  uiRoot.setState({ janusRoomId: defaultSpaceTopic.janus_room_id });
-  initialEnvironmentEl.setAttribute("gltf-bundle", `src: ${gltfBundleUrl}`);
+
+  const socketProtocol = document.location.protocol === "https:" ? "wss:" : "ws:";
+  const socketPort = qs.phx_port || document.location.port;
+  const socketHost = qs.phx_host || document.location.hostname;
+  const socketUrl = `${socketProtocol}//${socketHost}${socketPort ? `:${socketPort}` : ""}/socket`;
+  console.log(`Phoenix Channel URL: ${socketUrl}`);
+
+  const socket = new Socket(socketUrl, { params: { session_id: uuid() } });
+  socket.connect();
+
+  const channel = socket.channel(`hub:${hubId}`, {});
+
+  channel
+    .join()
+    .receive("ok", data => {
+      const hub = data.hubs[0];
+      const defaultSpaceTopic = hub.topics[0];
+      const gltfBundleUrl = defaultSpaceTopic.assets.find(a => a.asset_type === "gltf_bundle").src;
+      uiRoot.setState({ janusRoomId: defaultSpaceTopic.janus_room_id });
+      initialEnvironmentEl.setAttribute("gltf-bundle", `src: ${gltfBundleUrl}`);
+      hubChannel.setPhoenixChannel(channel);
+    })
+    .receive("error", res => console.error(res));
 };
 
 document.addEventListener("DOMContentLoaded", onReady);
diff --git a/src/storage/store.js b/src/storage/store.js
index 6f480aa76db6f14904b611cecf5b0b6b84b1f41f..67afe7841ce5eda0454faed6020b49d8f125a403 100644
--- a/src/storage/store.js
+++ b/src/storage/store.js
@@ -27,7 +27,8 @@ export const SCHEMA = {
   properties: {
     id: { type: "string", pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" },
     profile: { $ref: "#/definitions/profile" },
-    lastUsedMicDeviceId: { type: "string" }
+    lastUsedMicDeviceId: { type: "string" },
+    lastEnteredAt: { type: "string" }
   },
 
   additionalProperties: false
diff --git a/src/utils/hub-channel.js b/src/utils/hub-channel.js
new file mode 100644
index 0000000000000000000000000000000000000000..e4782e6faf20d3975c43531ba42aaf2d1e9c47f4
--- /dev/null
+++ b/src/utils/hub-channel.js
@@ -0,0 +1,50 @@
+export default class HubChannel {
+  constructor(store) {
+    this.store = store;
+  }
+
+  setPhoenixChannel = channel => {
+    this.channel = channel;
+  };
+
+  sendEntryEvent = async () => {
+    if (!this.channel) {
+      console.warn("No phoenix channel initialized before room entry.");
+      return;
+    }
+
+    let entryDisplayType = "Screen";
+
+    if (navigator.getVRDisplays) {
+      const vrDisplay = (await navigator.getVRDisplays()).find(d => d.isPresenting);
+
+      if (vrDisplay) {
+        entryDisplayType = vrDisplay.displayName;
+      }
+    }
+
+    // This is fairly hacky, but gets the # of initial occupants
+    let initialOccupantCount = 0;
+
+    if (NAF.connection.adapter && NAF.connection.adapter.publisher) {
+      initialOccupantCount = NAF.connection.adapter.publisher.initialOccupants.length;
+    }
+
+    const entryTimingFlags = { isNewDay: true, isNewMonth: false };
+
+    const entryEvent = {
+      ...entryTimingFlags,
+      initialOccupantCount,
+      entryDisplayType,
+      userAgent: navigator.userAgent
+    };
+
+    this.channel.push("events:entered", { body: entryEvent });
+  };
+
+  disconnect = () => {
+    if (this.channel) {
+      this.channel.socket.disconnect();
+    }
+  };
+}
diff --git a/yarn.lock b/yarn.lock
index 41b0864f101f538ab31881607f082cfbc8b50cd7..71bc975fb0808b404504874ff02778d4d4ece7e1 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5851,6 +5851,10 @@ performance-now@^0.2.0:
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-0.2.0.tgz#33ef30c5c77d4ea21c5a53869d91b56d8f2555e5"
 
+phoenix@^1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/phoenix/-/phoenix-1.3.0.tgz#1df2c27f986ee295e37c9983ec28ebac1d7f4a3e"
+
 pify@^2.0.0, pify@^2.3.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"