Messenger Video Queue + Notifications

Capture Facebook Messenger videos locally with push notifications and a dashboard.

Introduction

30 Second Summary

Think about how many videos get sent to businesses through messaging apps every day. Without automation, someone has to manually check the inbox, download each file, and figure out what to do with it.

In this project, you will build a local video queue system that captures videos sent to your Facebook Page via Messenger, stores them in a database, and alerts you through push notifications on your phone and a live web dashboard with sound alerts.

What You'll Build

A Node.js Express server integrated with Facebook Messenger webhooks, a SQLite database, a self-hosted notification server, and a real-time web dashboard.

By the end of this project, you'll have:

  • A running Node.js webhook server that receives Facebook Messenger video messages in real-time.
  • A self-hosted push notification system (ntfy) sending alerts to your phone when videos arrive.
  • A local web dashboard that displays queued videos with an audio alert on new arrivals.
  • Secret Mission: Add inline video playback and a "watched/unwatched" toggle to your dashboard.

Are there any prerequisites?

You need Node.js installed on your machine, a Facebook Page (any Page you manage), and a Facebook/Meta developer account.

If you do not have a Meta developer account yet, you can create one for free at developers.facebook.com during the project.

Create the Meta App and Configure Webhooks

Your local environment is ready with ngrok, ntfy, and the Node.js project scaffolded. Now it's time to connect to the outside world so Facebook can talk to your machine.

Facebook needs a registered app to know where to deliver messages that arrive on your Page. In this step, you'll create a Meta App, start an ngrok tunnel so Facebook can reach your local server, and wire up your Page to forward Messenger events.

In this step, get ready to:
  • Create a Meta App with the Messenger product enabled.
  • Start an ngrok tunnel and set the webhook callback URL.
  • Connect your Facebook Page and collect your access credentials.
Create a Meta App

A Meta App is the registration that tells Facebook where to send events when something happens on your Page. You'll create one on the Meta for Developers dashboard and add the Messenger product to it.

  • Open your browser and go to developers.facebook.com.
  • Click My Apps in the top navigation, then click Create App.
  • Select Business as the app type and click Next.
  • Give your app a name (e.g. Video Queue) and fill in the required fields.
  • Click Create App to finish.

You should land on your new app's Dashboard page.

  • On the Dashboard, scroll down and click Add Product.
  • Find Messenger in the product list and click Set Up.
  • In the left sidebar, click Messenger then Messenger Settings.
  • Scroll down to the Webhooks section. You will configure this next.

What is a webhook?

A webhook is an HTTP endpoint that another service calls when an event happens. Instead of your server repeatedly asking Facebook "any new messages?", Facebook pushes the event to your server the instant it occurs.

Connect ngrok and configure the webhook

Facebook requires a publicly accessible HTTPS URL for its webhook callback. Since your server runs locally, you need ngrok to create a tunnel that exposes your local port 3000 to the internet.

  • Open a new terminal window (keep your ntfy terminal running from earlier).
  • Start the ngrok tunnel by running this command:
ngrok http 3000

The ngrok agent prints a console UI showing it is online and forwarding. Look for the Forwarding line that shows your public HTTPS URL (e.g. https://abc123.ngrok-free.app).

  • Copy the full HTTPS URL from your ngrok output. You will need it in the next bullets.

Keep this terminal open

The ngrok tunnel only exists while the process is running. If you close this terminal, the URL stops working and Facebook can no longer reach your machine.

Now paste that URL into the Meta App Dashboard to register it as your webhook endpoint.

  • Switch back to your browser where the Messenger Settings page is open.
  • In the Webhooks section, click Add Callback URL.
  • In the Callback URL field, paste your ngrok HTTPS URL and append /webhook to the end (e.g. https://abc123.ngrok-free.app/webhook).
  • In the Verify Token field, enter my_video_queue_token.
  • Click Verify and Save.

Seeing a verification error? That is expected

Verification will fail at this point because your Express server is not running yet. Facebook sends a GET request to your callback URL, but nothing is listening on port 3000 to respond.

You will complete the verification in the next step after building and starting your server. For now, leave the callback URL and verify token in place. Do not delete them.

Subscribe your Page and collect credentials

Your Meta App needs to know which Facebook Page to monitor. You also need two credentials for your server code: a Page Access Token (for downloading attachments) and an App Secret (for verifying webhook signatures).

  • Still in Messenger Settings, scroll to the Webhooks section and click Add or remove Pages.
  • Select your Facebook Page from the list and grant the requested permissions.
  • Once your Page appears under the Webhooks section, find the messages field and click Subscribe next to it.

Why subscribe to messages?

The messages webhook field tells Facebook to notify your server whenever a customer sends a message to your Page. This includes text, images, and video attachments. Your server will filter for videos only.

  • Still in Messenger Settings, scroll to the Access Tokens section.
  • Click Generate Token next to your Page name.
  • Copy the token and save it somewhere safe (a text file or notes app). You will use it later in your server code.
  • In the left sidebar, click Settings then Basic.
  • Find the App Secret field, click Show, and copy the value to the same safe location.

Keep your credentials private

Your Page Access Token and App Secret are sensitive credentials. Never commit them to Git or share them publicly. You will paste them into your server code as environment variables in the next step.

Page not appearing in the list?

Make sure you are logged in to Facebook with the account that owns or administers the Page. If you recently created the Page, try refreshing the Meta App Dashboard.

help me connect my Facebook Page to my Meta App

Your Meta App is wired up and waiting. Next up, you'll build the Express server that actually receives these webhook events and saves video attachments to your machine.

Build the Webhook Server

Your Meta App is configured and waiting for your server to respond. Facebook tried to verify your webhook URL in the previous step, but nothing was listening on port 3000 yet.

In this step, you'll build the Express server that answers Facebook's verification challenge, processes incoming message events, and automatically downloads video attachments to your machine.

In this step, get ready to:
  • Create a SQLite database module that stores video metadata.
  • Build webhook endpoints that receive and verify Facebook Messenger events.
  • Wire up push notifications and API routes to complete the server.
Set up the database module

Before the server can process webhooks, it needs somewhere to store information about each video it receives. A SQLite database is perfect here because it lives as a single file on your machine with zero configuration.

  • In your code editor, open database.js inside your video-queue folder.
  • Replace the empty file with the following code:
const Database = require("better-sqlite3");
const path = require("path");

// Open or create the SQLite database file
const db = new Database(path.join(__dirname, "queue.db"));

// Enable Write-Ahead Logging for better performance
db.pragma("journal_mode = WAL");

// Create the videos table if it does not exist yet
db.exec(`
  CREATE TABLE IF NOT EXISTS videos (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    sender_id TEXT NOT NULL,
    filename TEXT NOT NULL,
    original_url TEXT NOT NULL,
    received_at TEXT NOT NULL DEFAULT (datetime('now')),
    status TEXT NOT NULL DEFAULT 'new'
  )
`);

// Insert a new video record
function insertVideo(senderId, filename, originalUrl) {
  const stmt = db.prepare(
    "INSERT INTO videos (sender_id, filename, original_url) VALUES (?, ?, ?)"
  );
  return stmt.run(senderId, filename, originalUrl);
}

// Retrieve all videos, newest first
function getAllVideos() {
  return db.prepare("SELECT * FROM videos ORDER BY received_at DESC").all();
}

// Retrieve only videos with status 'new'
function getNewVideos() {
  return db
    .prepare(
      "SELECT * FROM videos WHERE status = 'new' ORDER BY received_at DESC"
    )
    .all();
}

// Mark a video as watched by its ID
function markWatched(id) {
  const stmt = db.prepare("UPDATE videos SET status = 'watched' WHERE id = ?");
  return stmt.run(id);
}

module.exports = { insertVideo, getAllVideos, getNewVideos, markWatched };

What does this code do?

This module handles all database operations for your video queue.

  • better-sqlite3 opens (or creates) a file called queue.db in your project folder. No separate database server needed.
  • WAL (Write-Ahead Logging) mode improves performance when reading and writing happen at the same time.
  • The four exported functions give the rest of your app a clean interface for inserting, listing, and updating video records.
  • Save database.js.
  • Verify the database module loads correctly by running this command in your terminal:
node -e "require('./database')"
  • Confirm queue.db was created by running:
ls

You should see queue.db listed alongside your other project files.

Seeing a module not found error?

  • Make sure you are inside the video-queue folder when you run the command. Run cd ~/Desktop/video-queue if needed.
  • Confirm your node_modules folder exists. If not, run npm install again.

Help me fix a module not found error in my database.js file.

✔️ Awesome, I've got everything!

Great. Make sure you have saved database.js before moving on.

ⓧ I'd like to double check the full code

const Database = require("better-sqlite3");
const path = require("path");

const db = new Database(path.join(__dirname, "queue.db"));

db.pragma("journal_mode = WAL");

db.exec(`
  CREATE TABLE IF NOT EXISTS videos (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    sender_id TEXT NOT NULL,
    filename TEXT NOT NULL,
    original_url TEXT NOT NULL,
    received_at TEXT NOT NULL DEFAULT (datetime('now')),
    status TEXT NOT NULL DEFAULT 'new'
  )
`);

function insertVideo(senderId, filename, originalUrl) {
  const stmt = db.prepare(
    "INSERT INTO videos (sender_id, filename, original_url) VALUES (?, ?, ?)"
  );
  return stmt.run(senderId, filename, originalUrl);
}

function getAllVideos() {
  return db.prepare("SELECT * FROM videos ORDER BY received_at DESC").all();
}

function getNewVideos() {
  return db
    .prepare(
      "SELECT * FROM videos WHERE status = 'new' ORDER BY received_at DESC"
    )
    .all();
}

function markWatched(id) {
  const stmt = db.prepare("UPDATE videos SET status = 'watched' WHERE id = ?");
  return stmt.run(id);
}

module.exports = { insertVideo, getAllVideos, getNewVideos, markWatched };
Build the Express server foundation and webhook verification

Facebook's webhook system works in two phases. First, it sends a GET request to verify your server is real. Then it sends POST requests with actual message events. You need both endpoints for the pipeline to work.

  • Open server.js in your code editor.
  • Add the server initialization and webhook verification endpoint by pasting this code:
const express = require("express");
const path = require("path");
const fs = require("fs");
const https = require("https");
const http = require("http");
const crypto = require("crypto");
const { insertVideo, getAllVideos, getNewVideos, markWatched } = require("./database");

const app = express();
const PORT = 3000;

// Configuration values
const VERIFY_TOKEN = process.env.VERIFY_TOKEN || "my_video_queue_token";
const APP_SECRET = process.env.APP_SECRET || "your-app-secret-here";
const NTFY_URL = process.env.NTFY_URL || "http://localhost:8090/video-queue";

// Ensure the videos directory exists
const VIDEOS_DIR = path.join(__dirname, "videos");
if (!fs.existsSync(VIDEOS_DIR)) {
  fs.mkdirSync(VIDEOS_DIR);
}

// Parse JSON bodies and store raw body for signature verification
app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf; } }));
app.use(express.static(path.join(__dirname, "public")));

// Facebook webhook verification endpoint
app.get("/webhook", (req, res) => {
  const mode = req.query["hub.mode"];
  const token = req.query["hub.verify_token"];
  const challenge = req.query["hub.challenge"];

  if (mode === "subscribe" && token === VERIFY_TOKEN) {
    console.log("Webhook verified successfully.");
    res.status(200).send(challenge);
  } else {
    console.log("Webhook verification failed.");
    res.sendStatus(403);
  }
});

What does this code do?

The top section loads all the Node.js modules you need and configures key values like the verify token (which must match what you entered in the Meta App Dashboard).

The GET /webhook route handles Facebook's verification handshake. Facebook sends three query parameters: hub.mode, hub.verify_token, and hub.challenge. If the mode is subscribe and the token matches yours, the server echoes back the challenge value to prove it controls the URL.

You will not be able to run the server yet. The next chunk adds the event processing logic.

  • Now add the POST webhook handler and video download logic directly below the code you just pasted:
// Receive incoming message events from Facebook
app.post("/webhook", (req, res) => {
  const body = req.body;

  if (body.object !== "page") {
    return res.sendStatus(404);
  }

  // Respond immediately so Facebook does not retry
  res.status(200).send("EVENT_RECEIVED");

  // Process each messaging event
  body.entry.forEach((entry) => {
    const messaging = entry.messaging || [];
    messaging.forEach((event) => {
      if (event.message && event.message.attachments) {
        event.message.attachments.forEach((attachment) => {
          if (attachment.type === "video") {
            handleVideoAttachment(event.sender.id, attachment.payload.url);
          }
        });
      }
    });
  });
});

// Download a video from the temporary Facebook URL
function handleVideoAttachment(senderId, videoUrl) {
  const timestamp = Date.now();
  const filename = `video_${senderId}_${timestamp}.mp4`;
  const filepath = path.join(VIDEOS_DIR, filename);

  console.log(`Downloading video from ${senderId}...`);

  const file = fs.createWriteStream(filepath);
  const protocol = videoUrl.startsWith("https") ? https : http;

  protocol.get(videoUrl, (response) => {
    // Handle redirects from Facebook CDN
    if (response.statusCode === 301 || response.statusCode === 302) {
      protocol.get(response.headers.location, (redirected) => {
        redirected.pipe(file);
        redirected.on("end", () => onDownloadComplete(senderId, filename, videoUrl));
      });
      return;
    }
    response.pipe(file);
    file.on("finish", () => {
      file.close();
      onDownloadComplete(senderId, filename, videoUrl);
    });
  }).on("error", (err) => {
    console.error("Download error:", err.message);
    fs.unlink(filepath, () => {});
  });
}

// Save video metadata and trigger notification
function onDownloadComplete(senderId, filename, originalUrl) {
  console.log(`Video saved: ${filename}`);
  insertVideo(senderId, filename, originalUrl);
  sendNtfyNotification(senderId, filename);
}

What does this code do?

The POST /webhook route is where the real action happens. Facebook sends a JSON payload every time someone messages your Page. The server responds with a 200 status immediately (Facebook requires a response within 5 seconds), then processes the event asynchronously.

The code loops through each messaging event and checks for video attachments. When it finds one, it calls handleVideoAttachment() which downloads the video file to your local videos/ folder. Facebook's video URLs are temporary, so downloading immediately is critical.

Once the download finishes, onDownloadComplete() saves the metadata to your SQLite database and triggers a push notification.

The server still references sendNtfyNotification() which does not exist yet. You will define it in the next substep.

Worried about the undefined function?

This is fine. JavaScript hoists function declarations at the module level. Since you will define sendNtfyNotification() before starting the server, it will be available when the first webhook fires.

Help me understand why my server references an undefined function.

Wire up ntfy notifications and API endpoints

The server can receive webhooks and download videos, but it cannot notify you yet. The final piece connects your server to the ntfy instance running on your machine and adds API endpoints that the dashboard will use later.

  • Add the notification function, API routes, and server startup at the bottom of server.js:
// Send a push notification to the local ntfy server
function sendNtfyNotification(senderId, filename) {
  const message = `New video received from ${senderId}`;
  const url = new URL(NTFY_URL);

  const options = {
    hostname: url.hostname,
    port: url.port,
    path: url.pathname,
    method: "POST",
    headers: {
      "Title": "Video Queue Alert",
      "Content-Type": "text/plain",
    },
  };

  const req = http.request(options, (res) => {
    console.log(`Notification sent (status: ${res.statusCode})`);
  });

  req.on("error", (err) => {
    console.error("Notification error:", err.message);
  });

  req.write(message);
  req.end();
}

// API: return all videos
app.get("/api/videos", (req, res) => {
  const videos = getAllVideos();
  res.json(videos);
});

// API: return only new (unwatched) videos
app.get("/api/videos/new", (req, res) => {
  const videos = getNewVideos();
  res.json(videos);
});

// Start the server
app.listen(PORT, () => {
  console.log(`Video queue server running at http://localhost:${PORT}`);
  console.log(`Dashboard: http://localhost:${PORT}`);
  console.log(`Webhook URL: https://your-ngrok-url.ngrok-free.app/webhook`);
});

What does this code do?

sendNtfyNotification() sends an HTTP POST to your self-hosted ntfy server at http://localhost:8090/video-queue. The Title header sets the notification headline that appears on your phone.

The two API routes (/api/videos and /api/videos/new) serve JSON data from your SQLite database. The web dashboard you build in the next step will poll these endpoints every few seconds to update in real time.

  • Save server.js.
  • Start the server by running:
node server.js

You should see three log lines confirming the server is running on port 3000.

  • Switch back to the Meta App Dashboard in your browser.
  • In the Messenger Settings Webhooks section, click Verify and Save on your callback URL.
  • The verification should now succeed. Check your terminal for the log message Webhook verified successfully. to confirm.

Webhook verification failed?

  • Make sure your ngrok tunnel is still running in its terminal. If you closed it, restart it with ngrok http 3000 and update the callback URL in the Meta Dashboard with the new ngrok URL.
  • Confirm the verify token in your Meta App matches my_video_queue_token exactly. This is the value your server checks against.
  • Ensure node server.js is running without errors. If you see a syntax error, double-check you pasted all three code sections in order.

Help me fix my Facebook webhook verification failure.

✔️ Awesome, I've got everything!

Great. Confirm you have saved server.js and that the webhook shows as verified in your Meta App Dashboard.

ⓧ I'd like to double check the full code

const express = require("express");
const path = require("path");
const fs = require("fs");
const https = require("https");
const http = require("http");
const crypto = require("crypto");
const { insertVideo, getAllVideos, getNewVideos, markWatched } = require("./database");

const app = express();
const PORT = 3000;

const VERIFY_TOKEN = process.env.VERIFY_TOKEN || "my_video_queue_token";
const APP_SECRET = process.env.APP_SECRET || "your-app-secret-here";
const NTFY_URL = process.env.NTFY_URL || "http://localhost:8090/video-queue";

const VIDEOS_DIR = path.join(__dirname, "videos");
if (!fs.existsSync(VIDEOS_DIR)) {
  fs.mkdirSync(VIDEOS_DIR);
}

app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf; } }));
app.use(express.static(path.join(__dirname, "public")));

app.get("/webhook", (req, res) => {
  const mode = req.query["hub.mode"];
  const token = req.query["hub.verify_token"];
  const challenge = req.query["hub.challenge"];

  if (mode === "subscribe" && token === VERIFY_TOKEN) {
    console.log("Webhook verified successfully.");
    res.status(200).send(challenge);
  } else {
    console.log("Webhook verification failed.");
    res.sendStatus(403);
  }
});

app.post("/webhook", (req, res) => {
  const body = req.body;

  if (body.object !== "page") {
    return res.sendStatus(404);
  }

  res.status(200).send("EVENT_RECEIVED");

  body.entry.forEach((entry) => {
    const messaging = entry.messaging || [];
    messaging.forEach((event) => {
      if (event.message && event.message.attachments) {
        event.message.attachments.forEach((attachment) => {
          if (attachment.type === "video") {
            handleVideoAttachment(event.sender.id, attachment.payload.url);
          }
        });
      }
    });
  });
});

function handleVideoAttachment(senderId, videoUrl) {
  const timestamp = Date.now();
  const filename = `video_${senderId}_${timestamp}.mp4`;
  const filepath = path.join(VIDEOS_DIR, filename);

  console.log(`Downloading video from ${senderId}...`);

  const file = fs.createWriteStream(filepath);
  const protocol = videoUrl.startsWith("https") ? https : http;

  protocol.get(videoUrl, (response) => {
    if (response.statusCode === 301 || response.statusCode === 302) {
      protocol.get(response.headers.location, (redirected) => {
        redirected.pipe(file);
        redirected.on("end", () => onDownloadComplete(senderId, filename, videoUrl));
      });
      return;
    }
    response.pipe(file);
    file.on("finish", () => {
      file.close();
      onDownloadComplete(senderId, filename, videoUrl);
    });
  }).on("error", (err) => {
    console.error("Download error:", err.message);
    fs.unlink(filepath, () => {});
  });
}

function onDownloadComplete(senderId, filename, originalUrl) {
  console.log(`Video saved: ${filename}`);
  insertVideo(senderId, filename, originalUrl);
  sendNtfyNotification(senderId, filename);
}

function sendNtfyNotification(senderId, filename) {
  const message = `New video received from ${senderId}`;
  const url = new URL(NTFY_URL);

  const options = {
    hostname: url.hostname,
    port: url.port,
    path: url.pathname,
    method: "POST",
    headers: {
      "Title": "Video Queue Alert",
      "Content-Type": "text/plain",
    },
  };

  const req = http.request(options, (res) => {
    console.log(`Notification sent (status: ${res.statusCode})`);
  });

  req.on("error", (err) => {
    console.error("Notification error:", err.message);
  });

  req.write(message);
  req.end();
}

app.get("/api/videos", (req, res) => {
  const videos = getAllVideos();
  res.json(videos);
});

app.get("/api/videos/new", (req, res) => {
  const videos = getNewVideos();
  res.json(videos);
});

app.listen(PORT, () => {
  console.log(`Video queue server running at http://localhost:${PORT}`);
  console.log(`Dashboard: http://localhost:${PORT}`);
  console.log(`Webhook URL: https://your-ngrok-url.ngrok-free.app/webhook`);
});

Your webhook server is live and verified. Next up, you'll build the web dashboard that displays queued videos with a sound alert whenever a new one arrives.

Build the Web Dashboard

Your server is now receiving webhook events, downloading videos, and pushing notifications to ntfy. The missing piece is a way to see what has arrived without checking the terminal logs.

In this step, you will build a web dashboard that polls your API for new videos and displays them as cards. The dashboard also plays an audio beep when a new video lands, giving you instant feedback right in the browser.

In this step, get ready to:
  • Build the HTML structure and CSS for a dark-themed video dashboard.
  • Add JavaScript polling and a Web Audio API beep alert.
  • Connect your phone to the self-hosted ntfy server for mobile push notifications.
Build the dashboard HTML and CSS

The dashboard needs a header showing the queue title, a badge counting unread videos, and a container that renders video cards dynamically. You will write the HTML structure first, then style it.

  • Open public/index.html in your editor and replace its contents with the following HTML structure:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Video Queue</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <header>
    <h1>Video Queue</h1>
    <span id="badge" class="badge hidden">0</span>
    <span id="pulse" class="pulse hidden"></span>
  </header>
  <main>
    <div id="video-list"></div>
    <p id="empty-state">No videos received yet. Send a video to your Facebook Page via Messenger.</p>
  </main>

What does this HTML do?

The #badge span will display a red counter of unwatched videos. The #pulse span becomes a pulsing green dot when new videos exist.

The #video-list div is an empty container that JavaScript will fill with video cards. The #empty-state paragraph shows a helpful message when no videos have arrived yet.

You will add the JavaScript section to this file in the next substep. Leave the file open for now.

  • Open public/style.css and paste the base layout styles:
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  background: #1a1a2e;
  color: #e0e0e0;
  min-height: 100vh;
  padding: 20px;
}

header {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-bottom: 24px;
  padding-bottom: 16px;
  border-bottom: 1px solid #2a2a4a;
}

header h1 {
  font-size: 1.8rem;
  color: #ffffff;
}

.badge {
  background: #e63946;
  color: white;
  padding: 4px 10px;
  border-radius: 12px;
  font-size: 0.85rem;
  font-weight: bold;
}

.pulse {
  width: 10px;
  height: 10px;
  background: #06d6a0;
  border-radius: 50%;
  animation: pulse-animation 1.5s infinite;
}

@keyframes pulse-animation {
  0% { opacity: 1; transform: scale(1); }
  50% { opacity: 0.5; transform: scale(1.3); }
  100% { opacity: 1; transform: scale(1); }
}

.hidden {
  display: none;
}

main {
  max-width: 700px;
  margin: 0 auto;
}

#empty-state {
  text-align: center;
  color: #888;
  margin-top: 60px;
  font-size: 1.1rem;
}

What does this CSS do?

The global reset removes browser default spacing. The body uses a dark navy background (#1a1a2e) with light text for readability.

The .badge class creates a red pill-shaped counter. The .pulse class creates an animated green dot that scales up and fades rhythmically, drawing attention to new arrivals.

  • Add the video card styles below the existing CSS in public/style.css:
.video-card {
  background: #16213e;
  border: 1px solid #2a2a4a;
  border-radius: 8px;
  padding: 16px;
  margin-bottom: 12px;
  transition: opacity 0.3s;
}

.video-card.watched {
  opacity: 0.5;
}

.card-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 8px;
}

.status-badge {
  padding: 3px 8px;
  border-radius: 4px;
  font-size: 0.75rem;
  font-weight: bold;
  text-transform: uppercase;
}

.status-badge.new {
  background: #06d6a0;
  color: #1a1a2e;
}

.status-badge.watched {
  background: #4a4a6a;
  color: #aaa;
}

.timestamp {
  font-size: 0.8rem;
  color: #888;
}

.card-body .sender {
  font-size: 0.9rem;
  color: #bbb;
  margin-bottom: 4px;
}

.card-body .filename {
  font-size: 0.85rem;
  color: #666;
  font-family: monospace;
}

What do the card styles do?

Each .video-card is a dark blue box with a subtle border. When a video is marked as watched, the .watched class fades the card to 50% opacity so new videos stand out visually.

The .status-badge.new uses a bright green background matching the pulse dot. The .status-badge.watched uses a muted grey to signal that the video has been seen.

  • Save public/style.css.

✔️ Awesome, I've got everything!

Great. Double check you have saved public/style.css before moving on.

ⓧ I'd like to double check the full code

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  background: #1a1a2e;
  color: #e0e0e0;
  min-height: 100vh;
  padding: 20px;
}

header {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-bottom: 24px;
  padding-bottom: 16px;
  border-bottom: 1px solid #2a2a4a;
}

header h1 {
  font-size: 1.8rem;
  color: #ffffff;
}

.badge {
  background: #e63946;
  color: white;
  padding: 4px 10px;
  border-radius: 12px;
  font-size: 0.85rem;
  font-weight: bold;
}

.pulse {
  width: 10px;
  height: 10px;
  background: #06d6a0;
  border-radius: 50%;
  animation: pulse-animation 1.5s infinite;
}

@keyframes pulse-animation {
  0% { opacity: 1; transform: scale(1); }
  50% { opacity: 0.5; transform: scale(1.3); }
  100% { opacity: 1; transform: scale(1); }
}

.hidden {
  display: none;
}

main {
  max-width: 700px;
  margin: 0 auto;
}

#empty-state {
  text-align: center;
  color: #888;
  margin-top: 60px;
  font-size: 1.1rem;
}

.video-card {
  background: #16213e;
  border: 1px solid #2a2a4a;
  border-radius: 8px;
  padding: 16px;
  margin-bottom: 12px;
  transition: opacity 0.3s;
}

.video-card.watched {
  opacity: 0.5;
}

.card-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 8px;
}

.status-badge {
  padding: 3px 8px;
  border-radius: 4px;
  font-size: 0.75rem;
  font-weight: bold;
  text-transform: uppercase;
}

.status-badge.new {
  background: #06d6a0;
  color: #1a1a2e;
}

.status-badge.watched {
  background: #4a4a6a;
  color: #aaa;
}

.timestamp {
  font-size: 0.8rem;
  color: #888;
}

.card-body .sender {
  font-size: 0.9rem;
  color: #bbb;
  margin-bottom: 4px;
}

.card-body .filename {
  font-size: 0.85rem;
  color: #666;
  font-family: monospace;
}
Add polling and sound alerts

The dashboard needs to check for new videos automatically and alert you when one arrives. You will use the Web Audio API to generate a beep tone programmatically, so no audio file is needed.

  • Return to public/index.html and add the following script block below the closing </main> tag:
  <script>
    let lastCount = 0;
    let audioCtx = null;

    function playBeep() {
      if (!audioCtx) {
        audioCtx = new (window.AudioContext || window.webkitAudioContext)();
      }
      const oscillator = audioCtx.createOscillator();
      const gainNode = audioCtx.createGain();
      oscillator.connect(gainNode);
      gainNode.connect(audioCtx.destination);
      oscillator.frequency.value = 880;
      oscillator.type = "sine";
      gainNode.gain.value = 0.3;
      oscillator.start();
      oscillator.stop(audioCtx.currentTime + 0.2);
    }

What does playBeep do?

The playBeep() function creates an oscillator that plays an 880Hz sine wave (a high A note) at 30% volume for 0.2 seconds. This gives you a short, attention-grabbing beep without needing an external audio file.

The audioCtx variable is initialized lazily because browsers require a user gesture before playing audio. The first click on the page unlocks the audio context.

  • Add the fetchVideos function and initialization code directly below the playBeep function, still inside the same <script> tag:
    async function fetchVideos() {
      try {
        const response = await fetch("/api/videos");
        const videos = await response.json();

        const newCount = videos.filter(v => v.status === "new").length;
        const badge = document.getElementById("badge");
        const pulse = document.getElementById("pulse");

        if (newCount > 0) {
          badge.textContent = newCount;
          badge.classList.remove("hidden");
          pulse.classList.remove("hidden");
        } else {
          badge.classList.add("hidden");
          pulse.classList.add("hidden");
        }

        if (videos.length > lastCount && lastCount > 0) {
          playBeep();
        }
        lastCount = videos.length;

        const list = document.getElementById("video-list");
        const empty = document.getElementById("empty-state");

        if (videos.length === 0) {
          list.innerHTML = "";
          empty.classList.remove("hidden");
          return;
        }

        empty.classList.add("hidden");
        list.innerHTML = videos
          .map(
            (video) => `
          <div class="video-card ${video.status === "watched" ? "watched" : ""}">
            <div class="card-header">
              <span class="status-badge ${video.status}">${video.status}</span>
              <span class="timestamp">${new Date(video.received_at).toLocaleString()}</span>
            </div>
            <div class="card-body">
              <p class="sender">From: ${video.sender_id}</p>
              <p class="filename">${video.filename}</p>
            </div>
          </div>
        `
          )
          .join("");
      } catch (err) {
        console.error("Fetch error:", err);
      }
    }

    fetchVideos();
    setInterval(fetchVideos, 5000);

    document.addEventListener("click", () => {
      if (!audioCtx) {
        audioCtx = new (window.AudioContext || window.webkitAudioContext)();
      }
    }, { once: true });
  </script>
</body>
</html>

What does fetchVideos do?

The function polls GET /api/videos every 5 seconds. It counts how many videos have a "new" status and shows or hides the badge and pulse dot accordingly.

When the total video count increases (and is not the first load), playBeep() fires to alert you. Each video renders as a card showing the sender ID, timestamp, filename, and status badge.

The click event listener at the bottom initializes the AudioContext on the first user interaction, which is required by browser autoplay policies.

  • Save public/index.html.
  • Restart your server by pressing Ctrl+C in the terminal running node server.js, then run it again:
node server.js
  • Open http://localhost:3000 in your browser. You should see the dark-themed dashboard with the "No videos received yet" empty state message.

Seeing a blank page?

  • Make sure you saved both public/index.html and public/style.css.
  • Check that the public/ folder is at the same level as server.js. The Express static middleware serves from public/ relative to the project root.
  • Open your browser's DevTools console and look for any JavaScript errors.

help me debug my blank dashboard page

✔️ Awesome, I've got everything!

Great. Double check you have saved public/index.html before moving on.

ⓧ I'd like to double check the full code

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Video Queue</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <header>
    <h1>Video Queue</h1>
    <span id="badge" class="badge hidden">0</span>
    <span id="pulse" class="pulse hidden"></span>
  </header>
  <main>
    <div id="video-list"></div>
    <p id="empty-state">No videos received yet. Send a video to your Facebook Page via Messenger.</p>
  </main>
  <script>
    let lastCount = 0;
    let audioCtx = null;

    function playBeep() {
      if (!audioCtx) {
        audioCtx = new (window.AudioContext || window.webkitAudioContext)();
      }
      const oscillator = audioCtx.createOscillator();
      const gainNode = audioCtx.createGain();
      oscillator.connect(gainNode);
      gainNode.connect(audioCtx.destination);
      oscillator.frequency.value = 880;
      oscillator.type = "sine";
      gainNode.gain.value = 0.3;
      oscillator.start();
      oscillator.stop(audioCtx.currentTime + 0.2);
    }

    async function fetchVideos() {
      try {
        const response = await fetch("/api/videos");
        const videos = await response.json();

        const newCount = videos.filter(v => v.status === "new").length;
        const badge = document.getElementById("badge");
        const pulse = document.getElementById("pulse");

        if (newCount > 0) {
          badge.textContent = newCount;
          badge.classList.remove("hidden");
          pulse.classList.remove("hidden");
        } else {
          badge.classList.add("hidden");
          pulse.classList.add("hidden");
        }

        if (videos.length > lastCount && lastCount > 0) {
          playBeep();
        }
        lastCount = videos.length;

        const list = document.getElementById("video-list");
        const empty = document.getElementById("empty-state");

        if (videos.length === 0) {
          list.innerHTML = "";
          empty.classList.remove("hidden");
          return;
        }

        empty.classList.add("hidden");
        list.innerHTML = videos
          .map(
            (video) => `
          <div class="video-card ${video.status === "watched" ? "watched" : ""}">
            <div class="card-header">
              <span class="status-badge ${video.status}">${video.status}</span>
              <span class="timestamp">${new Date(video.received_at).toLocaleString()}</span>
            </div>
            <div class="card-body">
              <p class="sender">From: ${video.sender_id}</p>
              <p class="filename">${video.filename}</p>
            </div>
          </div>
        `
          )
          .join("");
      } catch (err) {
        console.error("Fetch error:", err);
      }
    }

    fetchVideos();
    setInterval(fetchVideos, 5000);

    document.addEventListener("click", () => {
      if (!audioCtx) {
        audioCtx = new (window.AudioContext || window.webkitAudioContext)();
      }
    }, { once: true });
  </script>
</body>
</html>
Connect your phone to ntfy

The dashboard gives you a browser-based alert, but you also want notifications on your phone when you are away from your desk. The ntfy app connects to your self-hosted server over your local network.

  • Install the ntfy app on your phone from the Google Play Store (Android) or App Store (iOS).
  • Find your PC's local IP address by running this command in a terminal:
ipconfig
  • Look for the IPv4 Address line under your active Wi-Fi or Ethernet adapter. It will look something like 192.168.1.100.
  • In the ntfy app on your phone, open the settings and add a new server.
  • Enter your self-hosted server URL using your LAN IP: http://your-pc-lan-ip:8090 (replace your-pc-lan-ip with the IPv4 address you found).
  • Subscribe to the topic video-queue.

Why does my phone need the LAN IP?

Your phone cannot reach localhost because that always refers to the device itself. The LAN IP is your PC's address on your local Wi-Fi network. Both your phone and PC must be connected to the same network for this to work.

  • Test the full pipeline by sending a video message to your Facebook Page from a different Facebook account.
  • Check your terminal for download logs, refresh http://localhost:3000 to see the video card appear, and confirm your phone receives a push notification from the video-queue topic.

Phone not receiving notifications?

  • Confirm your phone is on the same Wi-Fi network as your PC.
  • Verify the ntfy server is still running in its terminal (you should see ntfy serve --listen-http :8090 active).
  • Try opening http://your-pc-lan-ip:8090 in your phone's browser. If the ntfy web UI loads, the connection works and the issue is in the app's server configuration.

help me troubleshoot ntfy notifications not reaching my phone

Secret mission

Add Video Playback and Watch Status

Your dashboard shows video metadata, but you can't actually watch the videos without navigating to the files on disk. Can you add inline playback and a watched/unwatched toggle that persists to the database?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely on your local machine with free-tier services, so there are no ongoing costs.

Resources you used:

  • Running processes: Node.js server (port 3000), ngrok tunnel, ntfy server (port 8090).
  • The video-queue project folder on your Desktop (includes downloaded videos and the SQLite database).
  • The ntfy.exe binary on your system.
  • Your Meta App on developers.facebook.com.
  • The ntfy app subscription on your phone.

Keep everything running

No action needed. Choose this if you are still actively building or testing the video queue system.

  • Your Node.js server, ngrok tunnel, and ntfy server will keep running until you close their terminal windows or restart your machine.
  • The ntfy app on your phone will continue receiving notifications as long as your PC is on and connected to the same network.
  • Your Meta App remains active and will forward webhook events to your ngrok URL.

Pause - I'll come back to this later

Shut down running processes to free up system resources. Your project files stay on disk so you can pick up where you left off.

  • Switch to the terminal running your Node.js server and press Ctrl+C to stop it.
  • Switch to the terminal running ngrok and press Ctrl+C to stop the tunnel.
  • Switch to the terminal running ntfy and press Ctrl+C to stop the notification server.
  • To resume later, reopen three terminals and start each service again: ntfy serve --listen-http :8090, ngrok http 3000, and node server.js.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below to fully clean up.

  • Stop all running processes by pressing Ctrl+C in each terminal (Node.js server, ngrok, ntfy).
  • Delete the project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/video-queue
  • On Windows, run this instead:
rmdir /s /q $env:USERPROFILE\Desktop\video-queue
  • Delete the ntfy.exe binary from wherever you placed it (e.g. C:\tools\ntfy\).
  • Delete your Meta App: go to developers.facebook.com, open your app, navigate to Settings then Advanced, and click Delete App at the bottom of the page.
  • On your phone, open the ntfy app, unsubscribe from the video-queue topic, and uninstall the app if you no longer need it.

That's a Wrap!

That's a Wrap!

Nice work! You just built a fully local video queue pipeline that captures videos from Facebook Messenger, stores them in a database, and alerts you through both push notifications and a live web dashboard.

You've learned how to:

  • Built a Node.js Express webhook server that receives Facebook Messenger events through an ngrok tunnel, bridging cloud webhooks to your local machine.
  • Self-hosted ntfy on Windows to deliver push notifications to your phone over your local network.
  • Created a real-time web dashboard with polling and programmatic Web Audio API sound alerts, backed by a SQLite database that persists video metadata.
  • Secret Mission: Added inline video playback and a watched/unwatched status system to your dashboard.

Ready to quiz yourself?