Developer Guide: Building a Production-Ready SMS Scheduler with Node.js, Express, and Vonage
A step-by-step guide to creating an SMS scheduling application using Node.js, Express, Vonage API, node-cron, and PostgreSQL, covering setup, scheduling logic, database integration, and API creation.
Code examples16 min read
Frequently asked questions
Use Node.js with Express, the Vonage Messages API, and node-cron to schedule SMS messages. Set up an Express server to handle API requests, use the Vonage API to send messages, and node-cron to trigger messages at scheduled times, storing job details in a database like PostgreSQL.
The Vonage Messages API is a service that enables sending SMS messages programmatically. It's integrated into the Node.js application using the official Vonage Node.js SDK, @vonage/server-sdk, simplifying the process of sending text messages.
Node-cron provides a simple way to schedule tasks in a Node.js application, similar to cron jobs on a server. It's used to trigger the sending of scheduled SMS messages at specific times defined by cron expressions.
Create a Vonage application, enable the Messages capability, download your private key, and link your virtual number. Add the Application ID, private key path, and virtual number to your .env file, which Vonage uses to send scheduled messages. Go to Messages API settings and configure SMS defaults to use the Messages API
PostgreSQL, or similar relational databases, are recommended for persistent storage. The guide provides SQL examples for PostgreSQL but suggests using an ORM like Prisma or Sequelize for more complex projects.
The pgcrypto extension is required to use the gen_random_uuid() function, which generates UUIDs for identifying scheduled SMS jobs. Enable it with CREATE EXTENSION IF NOT EXISTS 'pgcrypto';
Run npm install express @vonage/server-sdk dotenv node-cron uuid pg or yarn add express @vonage/server-sdk dotenv node-cron uuid pg to install necessary dependencies for the SMS scheduling project, such as Express, Vonage SDK, and database connector.
The dotenv package helps manage environment variables. It loads variables from a .env file into process.env, allowing you to keep sensitive credentials like API keys and database connection strings separate from code. Ensure this file is in your .gitignore
The scheduler service checks for scheduled times that have already passed when the application starts or a job is scheduled. If a past-due job is found, it's marked as 'failed' in the database, preventing attempts to send the outdated message.
The '/schedule' API endpoint expects 'recipient', 'message', and 'scheduledTime' in the request body. The recipient must be in E.164 format, and scheduledTime should be a future date and time in ISO 8601 format.
ngrok is helpful during development to expose your local server and receive inbound webhooks from Vonage, like status updates or message replies, which are not necessary for the basic sending of scheduled SMS messages, but are helpful for future enhancements.
Send a DELETE request to /api/schedule/YOUR_JOB_ID (ensure a valid API Key). This will update the job status to 'cancelled', preventing the scheduled task from sending the message, and it will stop the task in memory on the current server.
The examples use PostgreSQL syntax, but you can adapt the SQL commands and the db.js configuration to use MySQL, SQLite, or other databases if preferred. You'll need the appropriate database client for Node (mysql2, sqlite3, etc.).
A 202 Accepted response to an API request to schedule an SMS indicates that the request has been accepted for processing but hasn't been completed yet. The SMS will be sent at the specified scheduled time.
One API. Every channel.
Contact Sales
This guide provides a step-by-step walkthrough for creating a robust SMS scheduling and reminder application using Node.js, Express, and the Vonage Messages API. We'll cover everything from project setup and core scheduling logic to database integration, error handling, security, deployment, and verification.
The goal is to build a system where users can schedule SMS messages to be sent at a specific future date and time via a simple API endpoint. This solves common needs like appointment reminders, notification scheduling, and future alerts.
Technologies Used:
@vonage/server-sdk: The official Vonage Node.js SDK.node-cron: A simple cron-like job scheduler for Node.js.dotenv: For managing environment variables.uuid: For generating unique job identifiers.System Architecture:
(Note: ASCII diagrams may render differently depending on your viewer.)
Prerequisites:
pgcryptoExtension: Required forgen_random_uuid(). Ensure it's enabled in your database (CREATE EXTENSION IF NOT EXISTS 'pgcrypto';).ngrok: Useful for testing inbound webhooks if you extend this project later. ngroknpm install -g @vonage/cli1. Setting Up the Project
Let's initialize our Node.js project and install the necessary dependencies.
Create Project Directory:
mkdir sms-scheduler cd sms-schedulerInitialize npm Project:
Install Dependencies:
npm install express @vonage/server-sdk dotenv node-cron uuid pg # Or using yarn: # yarn add express @vonage/server-sdk dotenv node-cron uuid pgexpress: Web framework.@vonage/server-sdk: Vonage API client.dotenv: Loads environment variables from a.envfile.node-cron: Task scheduler.uuid: Generates unique IDs for jobs.pg: PostgreSQL client for Node.js (replace withmysql2,sqlite3, etc., if using a different DB).Install Development Dependencies (Optional but Recommended):
npm install --save-dev nodemon # Or using yarn: # yarn add --dev nodemonnodemon: Automatically restarts the server during development. Add a script topackage.json:// package.json "scripts": { "start": "node server.js", "dev": "nodemon server.js" },Create
.envFile: Create a file named.envin the project root to store sensitive credentials and configuration. Never commit this file to version control. Add a.gitignorefile with.envandnode_modules/.Set up Vonage Application:
private.keyfile that downloads into your project's root directory (or updateVONAGE_PRIVATE_KEY_PATHin.env). The public key is stored by Vonage.https://<your-ngrok-id>.ngrok.io/webhooks/statusand/inbound)..envfile.+15551234567) to your.envfile asVONAGE_SMS_FROM_NUMBER.Project Structure (Basic):
(We've added folders for better organization)
Basic Server Setup (
server.js):// server.js require('dotenv').config(); // Load environment variables first const express = require('express'); const app = express(); const PORT = process.env.PORT || 3000; // Middleware app.use(express.json()); // Parse JSON request bodies // Simple root route app.get('/', (req, res) => { res.send('SMS Scheduler API is running!'); }); // Start the server app.listen(PORT, () => { console.log(`Server listening on port ${PORT}`); });Run
npm run dev(if using nodemon) ornpm startto test. You should see the console message and be able to accesshttp://localhost:3000in your browser.2. Database Schema and Data Layer
We need a database table to store information about the scheduled SMS jobs.
Database Schema (
ScheduledJobsTable): We'll use PostgreSQL syntax. Adapt if needed for other databases.-- Connect to your database using psql or a GUI tool first -- Note: The use of gen_random_uuid() requires the pgcrypto extension. -- Enable it in your database if it's not already enabled: -- CREATE EXTENSION IF NOT EXISTS 'pgcrypto'; CREATE TABLE ScheduledJobs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Or SERIAL for integer IDs recipient VARCHAR(20) NOT NULL, -- E.164 phone number format message TEXT NOT NULL, scheduled_time TIMESTAMPTZ NOT NULL, -- Timestamp with time zone (IMPORTANT!) status VARCHAR(15) NOT NULL DEFAULT 'pending', -- pending, processing, sent, failed, cancelled vonage_message_id VARCHAR(50) NULL, -- Store Vonage's ID after sending error_message TEXT NULL, -- Store error details on failure created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); -- Create indexes for performance CREATE INDEX idx_scheduledjobs_status_time ON ScheduledJobs (status, scheduled_time);id: Unique identifier for the job (UUID recommended).recipient: Target phone number.message: SMS content.scheduled_time: The crucial field – when the SMS should be sent. UsingTIMESTAMPTZstores the timestamp in UTC along with time zone information, avoiding ambiguity.status: Tracks the job's lifecycle.vonage_message_id: Useful for tracking delivery status later.error_message: For debugging failures.Database Connection Setup: Create a simple database connection module.
// db.js (create this file in the root or a 'config' folder) const { Pool } = require('pg'); const pool = new Pool({ connectionString: process.env.DATABASE_URL, // Optional: Add SSL configuration for production databases // ssl: { // rejectUnauthorized: false // Adjust as per your provider's requirements // } }); pool.on('connect', () => { console.log('Connected to the Database'); }); pool.on('error', (err) => { console.error('Database connection error:', err.stack); // Consider adding logic to attempt reconnection or exit gracefully // For critical apps, you might want to exit if the pool errors out badly // process.exit(1); }); module.exports = { query: (text, params) => pool.query(text, params), pool // Export pool if needed for transactions etc. };Data Access Functions: Place these functions in
db/jobs.js.// db/jobs.js const db = require('../db'); // Adjust path to db.js if needed const { v4: uuidv4 } = require('uuid'); async function createJob(recipient, message, scheduledTime) { const queryText = ` INSERT INTO ScheduledJobs (id, recipient, message, scheduled_time, status) VALUES ($1, $2, $3, $4, $5) RETURNING *; `; // Ensure scheduledTime is a valid Date object or ISO string parsable by Postgres const isoScheduledTime = new Date(scheduledTime).toISOString(); const values = [uuidv4(), recipient, message, isoScheduledTime, 'pending']; try { const res = await db.query(queryText, values); console.log(`Job created with ID: ${res.rows[0].id}`); return res.rows[0]; } catch (err) { console.error('Error creating job:', err); throw err; // Re-throw to be handled by caller } } async function findJobById(id) { const queryText = `SELECT * FROM ScheduledJobs WHERE id = $1;`; try { const res = await db.query(queryText, [id]); return res.rows[0]; // Returns the job object or undefined if not found } catch (err) { console.error(`Error finding job by ID ${id}:`, err); throw err; } } async function findPendingJobs() { // Fetch ALL pending jobs. Logic to handle past-due ones is in schedulerService. const queryText = ` SELECT * FROM ScheduledJobs WHERE status = 'pending' ORDER BY scheduled_time ASC; `; try { const res = await db.query(queryText); return res.rows; } catch (err) { console.error('Error fetching pending jobs:', err); throw err; } } async function updateJobStatus(id, status, vonageMessageId = null, errorMessage = null) { const queryText = ` UPDATE ScheduledJobs SET status = $1, vonage_message_id = $2, error_message = $3, updated_at = NOW() WHERE id = $4 RETURNING *; `; const values = [status, vonageMessageId, errorMessage, id]; try { const res = await db.query(queryText, values); if (res.rowCount === 0) { console.warn(`Job ID ${id} not found for status update.`); return null; } console.log(`Job ${id} status updated to ${status}`); return res.rows[0]; } catch (err) { console.error(`Error updating job ${id} status:`, err); throw err; } } module.exports = { createJob, findJobById, findPendingJobs, updateJobStatus, };(Note: This uses raw SQL. An ORM like Prisma or Sequelize simplifies this significantly).
3. Implementing Core Functionality: Scheduling and Sending
Now, let's integrate
node-cronwith our database jobs and Vonage.Initialize Vonage SDK: Create
config/vonage.js.// config/vonage.js const { Vonage } = require('@vonage/server-sdk'); const fs = require('fs'); const path = require('path'); // Use path for robust file path handling const privateKeyPath = process.env.VONAGE_PRIVATE_KEY_PATH ? path.resolve(process.env.VONAGE_PRIVATE_KEY_PATH) // Resolve relative to project root : null; let privateKey; if (privateKeyPath) { try { privateKey = fs.readFileSync(privateKeyPath); } catch (err) { console.error(`Error reading private key from ${privateKeyPath}:`, err); // Handle error appropriately - exit if key is essential process.exit(1); } } else { console.error('VONAGE_PRIVATE_KEY_PATH is not defined in .env'); process.exit(1); } if (!process.env.VONAGE_APPLICATION_ID || !privateKey) { console.error('Vonage Application ID or Private Key not configured or loaded correctly.'); process.exit(1); } const vonage = new Vonage({ applicationId: process.env.VONAGE_APPLICATION_ID, privateKey: privateKey // Pass the buffer directly }, { debug: process.env.NODE_ENV !== 'production' }); // Enable debug logging in non-prod console.log('Vonage SDK Initialized'); module.exports = vonage;Scheduling Service Logic: Create
services/schedulerService.js.// services/schedulerService.js const cron = require('node-cron'); const vonage = require('../config/vonage'); // Use the initialized SDK const { createJob, findJobById, findPendingJobs, updateJobStatus } = require('../db/jobs'); // Use DB functions const scheduledTasks = new Map(); // Keep track of active cron jobs in memory // Function to send SMS via Vonage async function sendSms(jobId, recipient, message) { console.log(`Attempting to send SMS for job ${jobId} to ${recipient}`); await updateJobStatus(jobId, 'processing'); // Mark as processing try { const resp = await vonage.messages.send({ to: recipient, from: process.env.VONAGE_SMS_FROM_NUMBER, channel: 'sms', message_type: 'text', text: message, }); console.log(`SMS sent successfully for job ${jobId}. Vonage Message ID: ${resp.message_uuid}`); await updateJobStatus(jobId, 'sent', resp.message_uuid); return true; } catch (err) { // Extract meaningful error message from Vonage response if available const detail = err.response?.data?.detail || err.response?.data?.title || 'No specific detail provided'; const errorMessage = `Vonage API Error: ${err.message || 'Unknown error'} - ${detail}`; console.error(`Error sending SMS for job ${jobId}:`, errorMessage, err.response?.data); await updateJobStatus(jobId, 'failed', null, errorMessage); return false; } } // Function to schedule a single job using node-cron function scheduleCronJob(job) { if (scheduledTasks.has(job.id)) { console.log(`Job ${job.id} is already scheduled in memory. Skipping.`); return; // Avoid duplicate scheduling } const scheduledDate = new Date(job.scheduled_time); // Check if the scheduled time is in the past *upon scheduling* // This handles jobs loaded on startup that were missed during downtime. // Note: There's a minor potential race condition if the time is *extremely* close. if (scheduledDate <= new Date()) { console.warn(`Job ${job.id} scheduled time (${scheduledDate.toISOString()}) is in the past or now. Marking as failed.`); // Mark as failed instead of just skipping silently updateJobStatus(job.id, 'failed', null, 'Scheduled time already passed upon scheduler initialization').catch(err => { console.error(`Error updating status for past-due job ${job.id}:`, err); // Log error if status update fails }); return; } // Convert scheduledDate to cron pattern components const minute = scheduledDate.getUTCMinutes(); const hour = scheduledDate.getUTCHours(); const dayOfMonth = scheduledDate.getUTCDate(); const month = scheduledDate.getUTCMonth() + 1; // Cron months are 1-12 const dayOfWeek = '*'; // Run regardless of day of week const cronExpression = `${minute} ${hour} ${dayOfMonth} ${month} ${dayOfWeek}`; console.log(`Scheduling job ${job.id} with cron expression: ${cronExpression} for time: ${scheduledDate.toISOString()} (UTC)`); const task = cron.schedule(cronExpression, async () => { console.log(`Executing cron task for job ${job.id} at ${new Date().toISOString()}`); try { // Fetch latest job details to ensure it wasn't cancelled since scheduling const currentJob = await findJobById(job.id); if (currentJob && currentJob.status === 'pending') { await sendSms(job.id, job.recipient, job.message); } else { console.log(`Job ${job.id} status is '${currentJob?.status || 'not found'}', not 'pending'. Skipping send.`); // If cancelled or already processed, don't send. Status is already updated. } } catch (error) { console.error(`Unexpected error executing cron task for job ${job.id}:`, error); // Attempt to mark as failed if it wasn't already (e.g., DB error during findJobById) try { await updateJobStatus(job.id, 'failed', null, `Cron execution error: ${error.message}`); } catch (updateErr) { console.error(`Failed to update job ${job.id} status after execution error:`, updateErr); } } finally { // Remove the task from memory after execution attempt scheduledTasks.delete(job.id); // Explicitly stop the task to ensure it doesn't run again // (though with a specific date cron, it shouldn't) task.stop(); console.log(`Cron task stopped and removed for job ${job.id}`); } }, { scheduled: true, timezone: 'Etc/UTC' // IMPORTANT: Run cron based on UTC to match TIMESTAMPTZ }); scheduledTasks.set(job.id, task); // Store the task reference } // Function to load and schedule pending jobs on application startup async function initializeScheduler() { console.log('Initializing scheduler: Loading pending jobs...'); try { const pendingJobs = await findPendingJobs(); // Fetches all with status 'pending' console.log(`Found ${pendingJobs.length} pending jobs to potentially schedule.`); pendingJobs.forEach(job => { scheduleCronJob(job); // scheduleCronJob handles past-due jobs }); console.log('Scheduler initialization complete.'); } catch (error) { console.error('FATAL: Error initializing scheduler:', error); // Decide if this is a fatal error - often it is if jobs can't be loaded. throw error; // Propagate error to potentially stop server startup } } // Function called by the API to create and schedule a new job async function scheduleNewSms(recipient, message, scheduledTime) { // 1. Validation happens in the API route layer before calling this // 2. Create job in DB const newJob = await createJob(recipient, message, scheduledTime); // 3. Schedule the cron task scheduleCronJob(newJob); return newJob; // Return the created job details } // Function to cancel a job async function cancelScheduledSms(jobId) { const task = scheduledTasks.get(jobId); if (task) { task.stop(); scheduledTasks.delete(jobId); console.log(`Active cron task stopped and removed for job ${jobId}`); } else { console.warn(`No active cron task found in memory for job ${jobId} to cancel (might be already executed, failed, or server restarted).`); } // Update status in DB regardless (idempotent) console.log(`Attempting to mark job ${jobId} as cancelled in DB.`); return await updateJobStatus(jobId, 'cancelled'); } module.exports = { scheduleNewSms, initializeScheduler, cancelScheduledSms };Initialize Scheduler on Startup: Modify
server.jsto callinitializeSchedulerand handle potential startup errors.// server.js require('dotenv').config(); const express = require('express'); const db = require('./db'); // Import db module const { initializeScheduler } = require('./services/schedulerService'); // Import the initializer const scheduleRoutes = require('./routes/scheduleRoutes'); // Import routes const { apiKeyAuthMiddleware } = require('./middleware/auth'); // Import auth middleware (create next) const app = express(); const PORT = process.env.PORT || 3000; // --- Middleware --- app.use(express.json()); // Add other middleware like helmet, cors if needed // app.use(helmet()); // Example // --- Routes --- app.get('/', (req, res) => { res.send('SMS Scheduler API is running!'); }); app.get('/health', async (req, res) => { try { await db.query('SELECT 1'); // Check DB connection res.status(200).json({ status: 'OK', database: 'connected' }); } catch (error) { console.error('Health check failed:', error); res.status(503).json({ status: 'Error', database: 'disconnected', message: error.message }); } }); // Apply API Key Auth to schedule routes app.use('/api/schedule', apiKeyAuthMiddleware, scheduleRoutes); // --- Centralized Error Handler --- // eslint-disable-next-line no-unused-vars app.use((err, req, res, next) => { console.error("Unhandled Error:", err.stack || err); const statusCode = err.statusCode || 500; const message = err.message || 'An unexpected internal server error occurred.'; res.status(statusCode).json({ status: 'error', statusCode: statusCode, message: message, }); }); // --- Server Startup and Scheduler Initialization --- const server = app.listen(PORT, async () => { console.log(`Server listening on port ${PORT}`); try { // Verify DB connection before proceeding await db.query('SELECT NOW()'); console.log('Database connection verified.'); // Initialize the scheduler after DB is confirmed await initializeScheduler(); } catch (err) { console.error("FATAL: Failed to connect to DB or initialize scheduler on startup:", err); // If DB connection or scheduler init is critical, exit the process // This prevents the server from running in a potentially broken state. server.close(() => { console.log('Server shutting down due to critical startup error.'); process.exit(1); }); } }); // --- Graceful Shutdown --- const shutdown = (signal) => { console.log(`${signal} signal received: closing HTTP server`); server.close(async () => { console.log('HTTP server closed'); try { await db.pool.end(); // Close DB connections console.log('Database pool closed'); } catch (err) { console.error('Error closing database pool:', err); } finally { process.exit(0); } }); }; process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); // Handle Ctrl+C locally4. Building the API Layer
Let's create the Express routes to interact with our scheduler.
Create API Routes: Place in
routes/scheduleRoutes.js.// routes/scheduleRoutes.js const express = require('express'); const { scheduleNewSms, cancelScheduledSms } = require('../services/schedulerService'); const { findJobById } = require('../db/jobs'); // Use the implemented function const router = express.Router(); // Schedule a new SMS router.post('/', async (req, res, next) => { const { recipient, message, scheduledTime } = req.body; // --- Input Validation --- // Use a library like express-validator or joi for robust validation in production. if (!recipient || !message || !scheduledTime) { return res.status(400).json({ error: 'Missing required fields: recipient, message, scheduledTime (ISO 8601 format)' }); } // Basic ISO 8601 check and future date check if (isNaN(new Date(scheduledTime).getTime())) { return res.status(400).json({ error: 'Invalid scheduledTime format. Use ISO 8601 format (e.g., YYYY-MM-DDTHH:mm:ssZ).' }); } if (new Date(scheduledTime) <= new Date()) { return res.status(400).json({ error: 'Scheduled time must be in the future.' }); } // Basic E.164 format check (adjust regex as needed for strictness) if (!/^\+[1-9]\d{1,14}$/.test(recipient)) { return res.status(400).json({ error: 'Invalid recipient phone number format. Use E.164 format (e.g., +15551234567).' }); } // Consider adding length checks for the message if necessary. // --- End Validation --- try { const newJob = await scheduleNewSms(recipient, message, scheduledTime); // Respond with 202 Accepted: The request is accepted, processing will occur later. res.status(202).json({ message: 'SMS scheduled successfully.', jobId: newJob.id, status: newJob.status, scheduledTime: newJob.scheduled_time }); } catch (error) { console.error('API Error scheduling SMS:', error); // Pass error to central error handler next(error); } }); // Get Job Status by ID router.get('/:id', async (req, res, next) => { const { id } = req.params; // Basic ID validation (check if it looks like a UUID) if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(id)) { return res.status(400).json({ error: 'Invalid job ID format.' }); } try { const job = await findJobById(id); // Use the implemented function if (!job) { return res.status(404).json({ error: 'Job not found' }); } res.status(200).json(job); // Return the job details } catch (error) { console.error(`API Error getting job ${id}:`, error); next(error); // Pass to error handler } }); // Cancel a Scheduled SMS router.delete('/:id', async (req, res, next) => { const { id } = req.params; // Basic ID validation if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(id)) { return res.status(400).json({ error: 'Invalid job ID format.' }); } try { const cancelledJob = await cancelScheduledSms(id); if (!cancelledJob) { // Could be not found, or already processed/cancelled (updateJobStatus returns null if not found) // Check the job status directly if more specific feedback is needed const currentJob = await findJobById(id); if (!currentJob) { return res.status(404).json({ error: 'Job not found.' }); } else { // Job exists but wasn't pending or failed to update (unlikely if findJobById worked) return res.status(409).json({ // 409 Conflict might be suitable message: `Job found but could not be cancelled (current status: ${currentJob.status}).`, jobId: currentJob.id, status: currentJob.status }); } } // If cancelScheduledSms returned the job, it means the status was updated successfully res.status(200).json({ message: 'Scheduled SMS cancelled successfully.', jobId: cancelledJob.id, status: cancelledJob.status // Should be 'cancelled' }); } catch (error) { console.error(`API Error cancelling job ${id}:`, error); next(error); } }); module.exports = router;Mount Routes in
server.js: (Already done in the updatedserver.jsin Section 3).Testing with
curlor Postman: Start your server (npm run dev).Schedule SMS (requires valid API Key in header):
# Replace YOUR_API_KEY with one from your .env curl -X POST http://localhost:3000/api/schedule \ -H ""Content-Type: application/json"" \ -H ""X-API-Key: YOUR_API_KEY"" \ -d '{ ""recipient"": ""+15559876543"", ""message"": ""Your appointment is tomorrow at 10 AM."", ""scheduledTime"": ""2025-04-21T10:00:00Z"" # Use a future ISO 8601 date/time in UTC }'(Replace recipient number, API key, and adjust time) You should get a
202 Acceptedresponse with the job ID. Check server logs and database.Get Job Status (requires valid API Key):
# Replace YOUR_JOB_ID and YOUR_API_KEY curl -X GET http://localhost:3000/api/schedule/YOUR_JOB_ID_HERE \ -H ""X-API-Key: YOUR_API_KEY""You should get a
200 OKresponse with job details or404 Not Found.