diff --git a/package.json b/package.json
index f99e62b319a688c56cd4d53d7110e203d50fa31b..8b2851b5edcd8fc13942b1dc88882760fe7c4728 100644
--- a/package.json
+++ b/package.json
@@ -34,10 +34,13 @@
     "jsonschema": "^1.2.2",
     "minijanus": "^0.5.0",
     "mobile-detect": "^1.4.1",
+    "moment": "^2.22.0",
+    "moment-timezone": "^0.5.14",
     "moving-average": "^1.0.0",
     "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 9dc3247e3a033ba8bb08ddad32a33a0c9a899e8b..00dec151186d9527f6e76773eca67f5e002bf433 100644
--- a/src/hub.js
+++ b/src/hub.js
@@ -1,5 +1,8 @@
 import "./assets/stylesheets/hub.scss";
+import moment from "moment-timezone";
+import uuid from "uuid/v4";
 import queryString from "query-string";
+import { Socket } from "phoenix";
 
 import { patchWebGLRenderingContext } from "./utils/webgl";
 patchWebGLRenderingContext();
@@ -50,6 +53,7 @@ import "./components/hud-controller";
 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";
@@ -106,6 +110,7 @@ AFRAME.registerInputMappings(inputConfig, true);
 
 const store = new Store();
 const concurrentLoadDetector = new ConcurrentLoadDetector();
+const hubChannel = new HubChannel(store);
 
 concurrentLoadDetector.start();
 
@@ -113,6 +118,7 @@ concurrentLoadDetector.start();
 store.update({ profile: { ...generateDefaultProfile(), ...(store.state.profile || {}) } });
 
 async function exitScene() {
+  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);
@@ -186,6 +192,12 @@ async function enterScene(mediaStream, enterInVR, janusRoomId) {
   });
 
   if (!qsTruthy("offline")) {
+    document.body.addEventListener("connected", () => {
+      hubChannel.sendEntryEvent().then(() => {
+        store.update({ lastEnteredAt: moment().toJSON() });
+      });
+    });
+
     scene.components["networked-scene"].connect();
 
     if (mediaStream) {
@@ -274,15 +286,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..13e51b21b1e1f3702432a7a04c6804949c332b88
--- /dev/null
+++ b/src/utils/hub-channel.js
@@ -0,0 +1,75 @@
+import moment from "moment-timezone";
+
+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 = this.getEntryTimingFlags();
+
+    const entryEvent = {
+      ...entryTimingFlags,
+      initialOccupantCount,
+      entryDisplayType,
+      userAgent: navigator.userAgent
+    };
+
+    this.channel.push("events:entered", entryEvent);
+  };
+
+  getEntryTimingFlags = () => {
+    const entryTimingFlags = { isNewDaily: true, isNewMonthly: true, isNewDayWindow: true, isNewMonthWindow: true };
+
+    if (!this.store.state.lastEnteredAt) {
+      return entryTimingFlags;
+    }
+
+    const lastEntered = moment(this.store.state.lastEnteredAt);
+    const lastEnteredPst = moment(lastEntered).tz("America/Los_Angeles");
+    const nowPst = moment().tz("America/Los_Angeles");
+    const dayWindowAgo = moment().subtract(1, "day");
+    const monthWindowAgo = moment().subtract(1, "month");
+
+    entryTimingFlags.isNewDaily =
+      lastEnteredPst.dayOfYear() !== nowPst.dayOfYear() || lastEnteredPst.year() !== nowPst.year();
+    entryTimingFlags.isNewMonthly =
+      lastEnteredPst.month() !== nowPst.month() || lastEnteredPst.year() !== nowPst.year();
+    entryTimingFlags.isNewDayWindow = lastEntered.isBefore(dayWindowAgo);
+    entryTimingFlags.isNewMonthWindow = lastEntered.isBefore(monthWindowAgo);
+
+    return entryTimingFlags;
+  };
+
+  disconnect = () => {
+    if (this.channel) {
+      this.channel.socket.disconnect();
+    }
+  };
+}
diff --git a/yarn.lock b/yarn.lock
index 00fd724ba4590d8fb8f3d2a313e089d43e88f030..2f53dc8b77829473fcf0cc14e978f0eb226eaaaa 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5304,6 +5304,16 @@ module-deps@^6.0.0:
     through2 "^2.0.0"
     xtend "^4.0.0"
 
+moment-timezone@^0.5.14:
+  version "0.5.14"
+  resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.14.tgz#4eb38ff9538b80108ba467a458f3ed4268ccfcb1"
+  dependencies:
+    moment ">= 2.9.0"
+
+"moment@>= 2.9.0", moment@^2.22.0:
+  version "2.22.0"
+  resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.0.tgz#7921ade01017dd45186e7fee5f424f0b8663a730"
+
 move-concurrently@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"
@@ -6010,6 +6020,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"