Build a Notification Service with Rust

Build a full-stack notification service with a Rust backend and dual SDKs.

Introduction

30 Second Summary

Every app you use sends notifications. Order confirmations, chat messages, security alerts. Behind the scenes, companies pay hundreds of dollars a month for services that handle this routing and delivery.

In this project, you will build NotifyHub, a self-hosted notification service that queues and delivers messages across multiple channels through a single API. You'll write the Rust backend, create SDKs for other developers to use, and build a dashboard to monitor everything.

What You'll Build

A complete notification infrastructure with a Rust/Axum REST API, async queue-based delivery with retry logic, a Rust SDK crate, a Dart SDK package, and a Next.js monitoring dashboard.

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

  • A Rust backend that accepts notifications, queues them, and delivers via pluggable providers with exponential backoff retries.
  • Two published SDKs (a Rust crate and a Dart package) that developers can use to send notifications through your API.
  • A Next.js web dashboard showing delivery logs and letting you send test notifications.
  • Secret Mission: A preferences system where users choose which notification channels they receive on.

Are there any prerequisites?

This project assumes intermediate Rust knowledge (traits, async/await, error handling) and intermediate Flutter/Dart experience. You should also have basic familiarity with REST APIs and HTTP.

Set Up the Project Structure

Before you can build a notification service, you need a well-organized foundation. NotifyHub uses a Cargo workspace to share code between three Rust crates, plus a Next.js dashboard and a Dart SDK package.

In this step, you will scaffold the entire project: a workspace with three crates, a Next.js frontend, and a Dart package. By the end, every build tool will confirm that your structure compiles cleanly.

In this step, get ready to:
  • Create a Cargo workspace with three crates.
  • Initialize the Next.js dashboard and Dart SDK package.
  • Verify all tools build successfully.
Create the Cargo workspace and crates

A Cargo workspace lets multiple crates share a single target/ directory and Cargo.lock file. This keeps compile times fast and ensures consistent dependency versions across your server, SDK, and shared types crate.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project folder and move into it by running these commands:
mkdir notifyhub
cd notifyhub
  • Create the three Rust crates by running these commands:
cargo new notifyhub-server
cargo new notifyhub-rs --lib
cargo new notifyhub-types --lib

What do these crates do?

Each crate has a single responsibility. notifyhub-server is the Axum API backend. notifyhub-rs is the Rust SDK other developers will use. notifyhub-types holds shared data models that both the server and SDK import.

  • Create the workspace Cargo.toml at the project root by running:
rm Cargo.toml 2>/dev/null; cat > Cargo.toml << 'EOF'
[workspace]
members = ["notifyhub-server", "notifyhub-rs", "notifyhub-types"]
resolver = "2"
EOF
  • Open the notifyhub folder in your code editor.

Now you need to add dependencies to each crate.

  • Replace the contents of notifyhub-types/Cargo.toml with the following:
[package]
name = "notifyhub-types"
version = "0.1.0"
edition = "2021"

[dependencies]
serde = { version = "1", features = ["derive"] }
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }

What does this configure?

The serde crate handles serialization and deserialization. uuid generates unique IDs for each notification. chrono provides timestamps. The serde feature on both uuid and chrono means they can be serialized to JSON automatically.

  • Replace the contents of notifyhub-server/Cargo.toml with the following:
[package]
name = "notifyhub-server"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio-native-tls", "macros", "migrate"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.13", features = ["json"] }
thiserror = "2"
anyhow = "1"
notifyhub-types = { path = "../notifyhub-types" }
  • Replace the contents of notifyhub-rs/Cargo.toml with the following:
[package]
name = "notifyhub-rs"
version = "0.1.0"
edition = "2021"

[dependencies]
reqwest = { version = "0.13", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
tokio = { version = "1", features = ["full"] }
notifyhub-types = { path = "../notifyhub-types" }
  • Save all three Cargo.toml files.
  • Confirm the workspace compiles by running:
cargo build

You should see output ending with a successful compilation message and no errors.

Seeing a dependency error?

Make sure each path dependency (../notifyhub-types) matches the folder name you created with cargo new. Check that the workspace Cargo.toml lives in the notifyhub/ root directory, not inside a crate subfolder.

help me debug my Cargo workspace build error

Initialize the Next.js dashboard and Dart SDK

The dashboard gives you a web UI for viewing notifications and testing the API. The Dart SDK lets Flutter developers integrate with your service.

  • Create the Next.js dashboard by running this command from the notifyhub/ root:
npx create-next-app@latest dashboard

What prompts will I see?

The create-next-app wizard will ask you several questions. Accept the defaults for TypeScript, ESLint, and Tailwind CSS. Make sure App Router is enabled (it is by default).

  • Enter Yes for TypeScript.
  • Enter Yes for ESLint.
  • Enter Yes for Tailwind CSS.
  • Enter No for src/ directory.
  • Enter Yes for App Router.
  • Accept the default for Turbopack and import alias.
  • Create the Dart SDK package by running these commands:
mkdir -p notifyhub_dart/lib/src
cat > notifyhub_dart/pubspec.yaml << 'EOF'
name: notifyhub_dart
description: Dart SDK for the NotifyHub notification service.
version: 0.1.0

environment:
  sdk: ^3.0.0

dependencies:
  http: ^1.0.0
EOF

Why not use dart create?

For a lightweight SDK package, creating the files manually gives you full control over the structure. The http package is the standard library for making HTTP calls in Dart.

  • Create a placeholder library file so Dart has something to analyze:
cat > notifyhub_dart/lib/notifyhub_dart.dart << 'EOF'
/// NotifyHub Dart SDK
library notifyhub_dart;
EOF
Verify all builds pass

Each tool needs to confirm it can resolve dependencies and compile the scaffolding.

  • Verify the Cargo workspace builds by running:
cargo build
  • Verify the Next.js dashboard starts by running:
cd dashboard && npm run dev

You should see output indicating the dev server started on localhost:3000. Press Ctrl+C to stop it.

  • Return to the project root and verify the Dart package by running:
cd ..
dart pub get -C notifyhub_dart

You should see a message confirming dependencies were resolved successfully.

Dart pub get failing?

Make sure your pubspec.yaml is indented with spaces, not tabs. YAML is sensitive to whitespace. Also verify that dart is in your system PATH by running dart --version.

help me fix dart pub get in my NotifyHub project

  • Confirm the folder structure by running:
ls

You should see Cargo.toml, notifyhub-server, notifyhub-rs, notifyhub-types, dashboard, and notifyhub_dart in the output.

Your project skeleton is ready. Next up, you will define the core data models that power notifications, delivery tracking, and API key authentication.

Build the Core Data Models and Database

Your workspace compiles cleanly, which means every crate can find its neighbours. Now it's time to give those crates something real to work with.

Every notification system needs to answer three questions: what was sent, did it arrive, and who is allowed to send? In this step, you'll define the data models that answer those questions and wire them up to a SQLite database.

In this step, get ready to:
  • Define notification, delivery attempt, and API key models in the shared types crate.
  • Create a SQLite migration that builds the database schema.
  • Write a database initialization function with a connection pool.
Define the notification data models

The notifyhub-types crate is the single source of truth for data structures shared across the server, the Rust SDK, and tests. You'll define three structs here: one for notifications, one for delivery attempts, and one for API keys.

  • Before writing the models, add two dependencies that the structs need. Open notifyhub-types/Cargo.toml and update the [dependencies] section to match the following:
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
sqlx = { version = "0.8", features = ["macros", "sqlite"] }

What did you add?

You added serde_json so the notification payload can be a flexible JSON value.

You added sqlx with the macros and sqlite features so you can derive FromRow and Type directly on your structs. This lets sqlx automatically map database rows to your Rust types.

  • Save notifyhub-types/Cargo.toml.
  • Now open notifyhub-types/src/lib.rs and replace its contents with the following model definitions:
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

/// Status of a notification in the delivery pipeline
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, sqlx::Type)]
#[sqlx(rename_all = "lowercase")]
pub enum NotificationStatus {
    Queued,
    Sent,
    Failed,
}

/// A notification request stored in the system
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Notification {
    pub id: String,
    pub user_id: String,
    pub channel: String,
    #[sqlx(json)]
    pub payload: serde_json::Value,
    pub status: NotificationStatus,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

/// A record of a single delivery attempt
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct DeliveryAttempt {
    pub id: String,
    pub notification_id: String,
    pub attempt_number: i32,
    pub status: String,
    pub error_message: Option<String>,
    pub attempted_at: DateTime<Utc>,
}

/// An API key used for authenticating requests
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct ApiKey {
    pub id: String,
    pub key_hash: String,
    pub name: String,
    pub created_at: DateTime<Utc>,
}

What does this code do?

NotificationStatus is an enum that maps to lowercase text in the database (queued, sent, failed). The #[sqlx(rename_all = "lowercase")] attribute handles the conversion automatically.

Notification holds everything about a single notification request. The #[sqlx(json)] attribute on payload tells sqlx to deserialize the TEXT column as JSON into a serde_json::Value.

DeliveryAttempt logs each time the system tries to deliver a notification. ApiKey stores hashed keys for authenticating incoming API requests.

  • Save notifyhub-types/src/lib.rs.
  • Verify the types crate compiles by running:
cargo build -p notifyhub-types
  • You should see a successful compilation with no errors.

Seeing unresolved import errors?

Make sure you added both serde_json and sqlx to notifyhub-types/Cargo.toml. Double-check that sqlx has features ["macros", "sqlite"].

help me fix unresolved import errors in my notifyhub-types crate

Set up the SQLite migration

Migrations are SQL scripts that create or modify your database schema in a repeatable way. The sqlx::migrate!() macro reads these scripts at compile time and runs them when the server starts.

  • Create the migrations folder inside the server crate by running:
mkdir -p notifyhub-server/migrations
  • Create the initial migration file by running:
touch notifyhub-server/migrations/001_initial.sql
  • Open notifyhub-server/migrations/001_initial.sql and add the following SQL:
-- Stores all notification requests
CREATE TABLE notifications (
    id TEXT PRIMARY KEY,
    user_id TEXT NOT NULL,
    channel TEXT NOT NULL,
    payload TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'queued',
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL
);

-- Logs each delivery attempt for retry tracking
CREATE TABLE delivery_attempts (
    id TEXT PRIMARY KEY,
    notification_id TEXT NOT NULL REFERENCES notifications(id),
    attempt_number INTEGER NOT NULL,
    status TEXT NOT NULL,
    error_message TEXT,
    attempted_at TEXT NOT NULL
);

-- Stores hashed API keys for authentication
CREATE TABLE api_keys (
    id TEXT PRIMARY KEY,
    key_hash TEXT NOT NULL UNIQUE,
    name TEXT NOT NULL,
    created_at TEXT NOT NULL
);

What does this migration do?

It creates three tables that mirror your Rust structs. SQLite stores everything as TEXT for flexibility. The REFERENCES notifications(id) on delivery_attempts creates a foreign key relationship so every attempt links back to its notification.

The UNIQUE constraint on key_hash ensures no two API keys can have the same hash.

  • Save 001_initial.sql.
  • Confirm the file exists by running:
ls notifyhub-server/migrations/
  • You should see 001_initial.sql in the output.

Migration file not showing up?

Make sure you created the migrations folder inside notifyhub-server/, not in the workspace root. The path should be notifyhub-server/migrations/001_initial.sql.

help me find where my sqlx migration file should go in a Cargo workspace

Write the database initialization function

The server needs a function that connects to SQLite, runs any pending migrations, and returns a connection pool. A connection pool reuses database connections instead of opening a new one for every request.

  • First, update notifyhub-server/Cargo.toml to add the chrono and json features to sqlx. Find the sqlx line and update it to:
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio-native-tls", "macros", "migrate", "chrono", "json"] }

Why add chrono and json features?

The chrono feature teaches sqlx how to decode TEXT columns into DateTime<Utc> values at runtime. The json feature enables the #[sqlx(json)] attribute so your notification payload deserializes from TEXT to serde_json::Value automatically.

  • Save notifyhub-server/Cargo.toml.
  • Create the database module by running:
touch notifyhub-server/src/db.rs
  • Open notifyhub-server/src/db.rs and add the following code:
use sqlx::SqlitePool;

/// Connect to SQLite and run all pending migrations
pub async fn init_db(database_url: &str) -> SqlitePool {
    // Create a connection pool pointing at the SQLite file
    let pool = SqlitePool::connect(database_url)
        .await
        .expect("Failed to connect to database");

    // Run migrations from the ./migrations folder
    sqlx::migrate!()
        .run(&pool)
        .await
        .expect("Failed to run migrations");

    pool
}

What does this code do?

SqlitePool::connect opens a connection pool to the database file specified by the URL. If the file doesn't exist, SQLite creates it automatically.

sqlx::migrate!() embeds your SQL migration files at compile time and runs them against the database. This ensures the schema is always up to date when the server starts.

  • Now register the module with the server. Open notifyhub-server/src/main.rs and add this line at the top:
mod db;
  • Save both files.
  • Verify everything compiles by running:
cargo build
  • You should see a successful build with no errors across all workspace crates.

Build failing with migration errors?

The sqlx::migrate!() macro looks for a migrations folder relative to the crate's Cargo.toml. Make sure your folder structure is notifyhub-server/migrations/001_initial.sql (the folder sits next to notifyhub-server/Cargo.toml).

help me fix sqlx migration compile errors in my Cargo workspace

✔️ Awesome, I've got everything!

Great. Double check you've saved all your files.

ⓧ I'd like to double check the full code

Here are the three key files you created or modified in this step.

[package]
name = "notifyhub-types"
version = "0.1.0"
edition = "2021"

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
sqlx = { version = "0.8", features = ["macros", "sqlite"] }
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

/// Status of a notification in the delivery pipeline
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, sqlx::Type)]
#[sqlx(rename_all = "lowercase")]
pub enum NotificationStatus {
    Queued,
    Sent,
    Failed,
}

/// A notification request stored in the system
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Notification {
    pub id: String,
    pub user_id: String,
    pub channel: String,
    #[sqlx(json)]
    pub payload: serde_json::Value,
    pub status: NotificationStatus,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

/// A record of a single delivery attempt
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct DeliveryAttempt {
    pub id: String,
    pub notification_id: String,
    pub attempt_number: i32,
    pub status: String,
    pub error_message: Option<String>,
    pub attempted_at: DateTime<Utc>,
}

/// An API key used for authenticating requests
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct ApiKey {
    pub id: String,
    pub key_hash: String,
    pub name: String,
    pub created_at: DateTime<Utc>,
}
-- Stores all notification requests
CREATE TABLE notifications (
    id TEXT PRIMARY KEY,
    user_id TEXT NOT NULL,
    channel TEXT NOT NULL,
    payload TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'queued',
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL
);

-- Logs each delivery attempt for retry tracking
CREATE TABLE delivery_attempts (
    id TEXT PRIMARY KEY,
    notification_id TEXT NOT NULL REFERENCES notifications(id),
    attempt_number INTEGER NOT NULL,
    status TEXT NOT NULL,
    error_message TEXT,
    attempted_at TEXT NOT NULL
);

-- Stores hashed API keys for authentication
CREATE TABLE api_keys (
    id TEXT PRIMARY KEY,
    key_hash TEXT NOT NULL UNIQUE,
    name TEXT NOT NULL,
    created_at TEXT NOT NULL
);
use sqlx::SqlitePool;

/// Connect to SQLite and run all pending migrations
pub async fn init_db(database_url: &str) -> SqlitePool {
    // Create a connection pool pointing at the SQLite file
    let pool = SqlitePool::connect(database_url)
        .await
        .expect("Failed to connect to database");

    // Run migrations from the ./migrations folder
    sqlx::migrate!()
        .run(&pool)
        .await
        .expect("Failed to run migrations");

    pool
}

Your data layer is solid. Next up, you'll build the provider trait system that makes delivering notifications to different channels as simple as adding a new struct.

Build the Provider Trait System

Your database layer is ready to store notifications and track delivery attempts. Now you need the system that actually delivers those notifications to their destination.

The provider system abstracts delivery channels (webhook, console, email) behind a single trait. Adding a new channel later means implementing one method. No changes to the delivery engine, API, or database.

In this step, get ready to:
  • Define the NotificationProvider async trait and error types.
  • Implement ConsoleProvider and WebhookProvider.
  • Create a ProviderRegistry to map channel names to providers.
Define the NotificationProvider async trait

Rust traits define shared behavior across types. An async trait lets each provider implement its own delivery logic (HTTP call, logging, SMTP) behind the same interface.

  • Add the async-trait crate to your server by running this command from the notifyhub-server directory:
cd notifyhub-server
cargo add async-trait

What does this command do?

cargo add async-trait adds the async-trait crate to your Cargo.toml dependencies. This crate provides a macro that lets you define async methods inside traits.

  • Create a providers directory inside notifyhub-server/src/ by running:
mkdir src/providers
  • Create a new file called mod.rs inside src/providers/ and add the trait definition:
use async_trait::async_trait;
use notifyhub_types::Notification;
use thiserror::Error;

pub mod console;
pub mod registry;
pub mod webhook;

// Error type for provider failures
#[derive(Debug, Error)]
pub enum ProviderError {
    #[error("HTTP error: {0}")]
    HttpError(String),
    #[error("Serialization error: {0}")]
    SerializationError(String),
    #[error("Unknown error: {0}")]
    UnknownError(String),
}

// The core trait every delivery provider must implement
#[async_trait]
pub trait NotificationProvider: Send + Sync {
    async fn send(&self, notification: &Notification) -> Result<(), ProviderError>;
}

What does this code do?

ProviderError defines three failure cases a provider can encounter: HTTP failures, serialization issues, and catch-all unknown errors. The thiserror derive macro auto-generates Display and Error trait implementations.

The NotificationProvider trait requires Send + Sync bounds so providers can be shared safely across Tokio tasks. Any type implementing this trait needs just one method: send().

  • Open notifyhub-server/src/main.rs and add the module declaration at the top of the file:
mod providers;
  • Save both mod.rs and main.rs.

Seeing unresolved module errors?

The compiler will report errors about missing console, registry, and webhook modules. That is expected. You declared them in mod.rs but have not created the files yet. The next substeps fix this.

Help me understand why Rust requires module files to exist before compiling.

Implement the ConsoleProvider and WebhookProvider

Each provider is a struct that implements the NotificationProvider trait. The ConsoleProvider is the simplest. It prints notifications to stdout for local development and debugging.

  • Create console.rs inside src/providers/ and add the ConsoleProvider implementation:
use async_trait::async_trait;
use notifyhub_types::Notification;

use super::{NotificationProvider, ProviderError};

// A simple provider that logs notifications to the terminal
pub struct ConsoleProvider;

#[async_trait]
impl NotificationProvider for ConsoleProvider {
    async fn send(&self, notification: &Notification) -> Result<(), ProviderError> {
        println!(
            "[ConsoleProvider] Sending notification: id={}, channel={}, payload={}",
            notification.id, notification.channel, notification.payload
        );
        Ok(())
    }
}

What does this code do?

ConsoleProvider is a unit struct (no fields). Its send() method simply prints the notification's id, channel, and payload to stdout. It always returns Ok(()) because printing cannot fail in a meaningful way here.

  • Save console.rs.

Now for the WebhookProvider. This one makes a real HTTP POST request to deliver notifications to an external URL.

  • Create webhook.rs inside src/providers/ and add the WebhookProvider implementation:
use async_trait::async_trait;
use notifyhub_types::Notification;
use reqwest::Client;

use super::{NotificationProvider, ProviderError};

// A provider that delivers notifications via HTTP POST
pub struct WebhookProvider {
    pub url: String,
}

impl WebhookProvider {
    pub fn new(url: String) -> Self {
        Self { url }
    }
}

#[async_trait]
impl NotificationProvider for WebhookProvider {
    async fn send(&self, notification: &Notification) -> Result<(), ProviderError> {
        let client = Client::new();
        let response = client
            .post(&self.url)
            .json(&notification)
            .send()
            .await
            .map_err(|e| ProviderError::HttpError(e.to_string()))?;

        if !response.status().is_success() {
            return Err(ProviderError::HttpError(format!(
                "Non-2xx response: {}",
                response.status()
            )));
        }

        Ok(())
    }
}

What does this code do?

WebhookProvider stores a target URL. When send() is called, it creates a reqwest::Client and POSTs the entire notification as JSON to that URL.

If the HTTP request itself fails (network error, DNS resolution), map_err converts it to a ProviderError::HttpError. If the server responds with a non-2xx status code, that is also returned as an error. The delivery engine (built in the next step) uses these errors to decide whether to retry.

  • Save webhook.rs.

Seeing reqwest import errors?

Make sure reqwest with the json feature is in your notifyhub-server/Cargo.toml. It should already be there from Step 1. If missing, run cargo add reqwest --features json from the notifyhub-server directory.

Help me fix reqwest import issues in my Rust project.

Create the ProviderRegistry

The registry maps channel name strings (like "console" or "webhook") to their provider implementation. When a notification arrives for a given channel, the delivery engine looks up the right provider from this registry.

  • Create registry.rs inside src/providers/ and add the ProviderRegistry:
use std::collections::HashMap;
use std::sync::Arc;

use super::NotificationProvider;

// Maps channel names to their provider implementations
pub struct ProviderRegistry {
    providers: HashMap<String, Arc<dyn NotificationProvider>>,
}

impl ProviderRegistry {
    pub fn new() -> Self {
        Self {
            providers: HashMap::new(),
        }
    }

    // Register a provider for a given channel name
    pub fn register(&mut self, channel: String, provider: Arc<dyn NotificationProvider>) {
        self.providers.insert(channel, provider);
    }

    // Look up the provider for a channel
    pub fn get(&self, channel: &str) -> Option<Arc<dyn NotificationProvider>> {
        self.providers.get(channel).cloned()
    }
}

What does this code do?

Arc<dyn NotificationProvider> is a reference-counted pointer to any type that implements the trait. This lets multiple Tokio tasks share the same provider instance safely without cloning the provider itself.

The register() method inserts a provider under a channel name. The get() method returns a cloned Arc (cheap, just increments the reference count) or None if no provider exists for that channel.

Why a trait instead of an enum?

An enum would require modifying the core delivery code every time you add a new channel. With a trait, new providers are independent structs in their own files. The delivery engine works with any type behind the trait without knowing the concrete type.

This is the same pattern used by production notification services like Novu. It makes the system open to extension without modifying existing code.

  • Save registry.rs.
  • Verify the entire provider module compiles by running from the workspace root:
cd ..
cargo build

You should see a successful compilation with no errors. You may see warnings about unused code, which is fine at this stage.

Compilation failed?

Check that all four files exist in src/providers/: mod.rs, console.rs, webhook.rs, and registry.rs.

Make sure mod providers; appears in your main.rs. Without it, Rust does not know the providers directory is a module.

Help me debug my cargo build errors for the providers module.

✔️ Awesome, I've got everything!

Great. Double check you have saved all four provider files.

ⓧ I'd like to double check the full code

Here is each file in the providers module for comparison.

use async_trait::async_trait;
use notifyhub_types::Notification;
use thiserror::Error;

pub mod console;
pub mod registry;
pub mod webhook;

// Error type for provider failures
#[derive(Debug, Error)]
pub enum ProviderError {
    #[error("HTTP error: {0}")]
    HttpError(String),
    #[error("Serialization error: {0}")]
    SerializationError(String),
    #[error("Unknown error: {0}")]
    UnknownError(String),
}

// The core trait every delivery provider must implement
#[async_trait]
pub trait NotificationProvider: Send + Sync {
    async fn send(&self, notification: &Notification) -> Result<(), ProviderError>;
}
use async_trait::async_trait;
use notifyhub_types::Notification;

use super::{NotificationProvider, ProviderError};

// A simple provider that logs notifications to the terminal
pub struct ConsoleProvider;

#[async_trait]
impl NotificationProvider for ConsoleProvider {
    async fn send(&self, notification: &Notification) -> Result<(), ProviderError> {
        println!(
            "[ConsoleProvider] Sending notification: id={}, channel={}, payload={}",
            notification.id, notification.channel, notification.payload
        );
        Ok(())
    }
}
use async_trait::async_trait;
use notifyhub_types::Notification;
use reqwest::Client;

use super::{NotificationProvider, ProviderError};

// A provider that delivers notifications via HTTP POST
pub struct WebhookProvider {
    pub url: String,
}

impl WebhookProvider {
    pub fn new(url: String) -> Self {
        Self { url }
    }
}

#[async_trait]
impl NotificationProvider for WebhookProvider {
    async fn send(&self, notification: &Notification) -> Result<(), ProviderError> {
        let client = Client::new();
        let response = client
            .post(&self.url)
            .json(&notification)
            .send()
            .await
            .map_err(|e| ProviderError::HttpError(e.to_string()))?;

        if !response.status().is_success() {
            return Err(ProviderError::HttpError(format!(
                "Non-2xx response: {}",
                response.status()
            )));
        }

        Ok(())
    }
}
use std::collections::HashMap;
use std::sync::Arc;

use super::NotificationProvider;

// Maps channel names to their provider implementations
pub struct ProviderRegistry {
    providers: HashMap<String, Arc<dyn NotificationProvider>>,
}

impl ProviderRegistry {
    pub fn new() -> Self {
        Self {
            providers: HashMap::new(),
        }
    }

    // Register a provider for a given channel name
    pub fn register(&mut self, channel: String, provider: Arc<dyn NotificationProvider>) {
        self.providers.insert(channel, provider);
    }

    // Look up the provider for a channel
    pub fn get(&self, channel: &str) -> Option<Arc<dyn NotificationProvider>> {
        self.providers.get(channel).cloned()
    }
}

Your provider system is complete. You have a trait that any delivery channel can implement, two working providers, and a registry that maps channels to their implementations. Next up, you will build the async delivery engine that pulls queued notifications and routes them through these providers with retry logic.

Build the Async Delivery Engine

Your provider system can send notifications to different channels. But right now, there is no engine to actually pick up queued notifications and deliver them.

In this step, you will build a background worker that continuously polls for queued notifications and attempts delivery through the appropriate provider. If delivery fails, the engine retries with exponential backoff, giving transient failures time to resolve before marking a notification as permanently failed.

In this step, get ready to:
  • Create a Tokio-based background worker that picks up queued notifications.
  • Implement exponential backoff retry logic with max 3 attempts.
  • Log every delivery attempt to the database.
Create the delivery engine struct and background worker

The delivery engine needs access to two things: the database pool (to query and update notifications) and the provider registry (to look up which provider handles each channel).

  • Create a new file called delivery.rs inside the notifyhub-server/src/ folder.
  • Add the DeliveryEngine struct and start_worker() method by pasting this code:
use std::sync::Arc;
use sqlx::SqlitePool;
use tokio::time::{sleep, Duration};

use crate::providers::registry::ProviderRegistry;
use notifyhub_types::Notification;

// Holds the database pool and provider registry
pub struct DeliveryEngine {
    pool: SqlitePool,
    registry: Arc<ProviderRegistry>,
}

impl DeliveryEngine {
    // Create a new engine with database and provider access
    pub fn new(pool: SqlitePool, registry: Arc<ProviderRegistry>) -> Self {
        Self { pool, registry }
    }

    // Spawn a background task that polls for queued notifications every second
    pub fn start_worker(&self) {
        let pool = self.pool.clone();
        let registry = self.registry.clone();

        tokio::spawn(async move {
            loop {
                // Query all notifications waiting to be delivered
                let notifications = sqlx::query_as::<_, Notification>(
                    "SELECT * FROM notifications WHERE status = 'queued'"
                )
                .fetch_all(&pool)
                .await;

                if let Ok(notifications) = notifications {
                    for notification in notifications {
                        Self::attempt_delivery(&pool, &registry, &notification).await;
                    }
                }

                // Wait 1 second before polling again
                sleep(Duration::from_secs(1)).await;
            }
        });
    }
}

What does this code do?

DeliveryEngine stores a SqlitePool and an Arc<ProviderRegistry>. The Arc wrapper allows the registry to be safely shared across async tasks.

start_worker() uses tokio::spawn to launch a background task that runs indefinitely. Every second, it queries the notifications table for rows with status 'queued' and attempts delivery on each one.

Implement the delivery attempt with retry logic

When a delivery fails, you do not want to immediately give up. Transient network issues often resolve themselves after a short delay. Exponential backoff spaces out retries so each subsequent attempt waits longer before trying again.

  • In delivery.rs, add the attempt_delivery function inside the existing impl DeliveryEngine block, below the closing brace of start_worker():
    // Attempt to deliver a single notification with exponential backoff
    async fn attempt_delivery(
        pool: &SqlitePool,
        registry: &Arc<ProviderRegistry>,
        notification: &Notification,
    ) {
        // Count how many times we've already tried this notification
        let attempt_count: i64 = sqlx::query_scalar(
            "SELECT COUNT(*) FROM delivery_attempts WHERE notification_id = ?"
        )
        .bind(notification.id.to_string())
        .fetch_one(pool)
        .await
        .unwrap_or(0);

        let attempt_number = attempt_count + 1;

        // Look up the provider for this notification's channel
        let provider = match registry.get(&notification.channel) {
            Some(p) => p,
            None => {
                // No provider found for this channel, mark as failed
                sqlx::query("UPDATE notifications SET status = 'failed' WHERE id = ?")
                    .bind(notification.id.to_string())
                    .execute(pool)
                    .await
                    .ok();
                return;
            }
        };

        // Try to send the notification through the provider
        match provider.send(notification).await {
            Ok(_) => {
                // Log successful delivery attempt
                sqlx::query(
                    "INSERT INTO delivery_attempts (notification_id, attempt_number, status, error_message) VALUES (?, ?, 'success', NULL)"
                )
                .bind(notification.id.to_string())
                .bind(attempt_number)
                .execute(pool)
                .await
                .ok();

                // Update notification status to sent
                sqlx::query("UPDATE notifications SET status = 'sent' WHERE id = ?")
                    .bind(notification.id.to_string())
                    .execute(pool)
                    .await
                    .ok();
            }
            Err(e) => {
                // Log failed delivery attempt
                sqlx::query(
                    "INSERT INTO delivery_attempts (notification_id, attempt_number, status, error_message) VALUES (?, ?, 'failed', ?)"
                )
                .bind(notification.id.to_string())
                .bind(attempt_number)
                .bind(e.to_string())
                .execute(pool)
                .await
                .ok();

                if attempt_number >= 3 {
                    // Max retries exhausted, mark notification as permanently failed
                    sqlx::query("UPDATE notifications SET status = 'failed' WHERE id = ?")
                        .bind(notification.id.to_string())
                        .execute(pool)
                        .await
                        .ok();
                } else {
                    // Exponential backoff: 2^attempt_count seconds (1s, 2s, 4s)
                    let delay = Duration::from_secs(2u64.pow(attempt_count as u32));
                    sleep(delay).await;
                }
            }
        }
    }

What does this code do?

  • sqlx::query_scalar counts how many previous delivery attempts exist for this notification. The i64 return type is the standard for SQL COUNT operations.
  • registry.get(&notification.channel) looks up which provider handles the notification's channel. If no provider is registered, the notification is marked as failed immediately.
  • On success, a row is inserted into delivery_attempts with status 'success', and the notification status becomes 'sent'.
  • On failure, a row is inserted with status 'failed' and the error message. If this was the 3rd attempt, the notification is permanently marked as failed. Otherwise, the engine sleeps for 2^attempt_count seconds (1s after the first failure, 2s after the second) before the next polling cycle retries it.

Why exponential backoff?

Fixed-interval retries can overwhelm a failing service. Exponential backoff gives the downstream service progressively more time to recover between each attempt. This is the same pattern used by AWS SQS, Google Cloud Pub/Sub, and most production message queues.

The 1s, 2s, 4s progression means transient issues (like a brief network blip) resolve quickly, while persistent failures are abandoned after a reasonable number of attempts without wasting resources.

  • Save delivery.rs.
  • Declare the new module in your server crate. Open notifyhub-server/src/main.rs (or lib.rs, depending on your crate structure) and add this line alongside your other module declarations:
mod delivery;
  • Save the file and verify the project compiles by running:
cargo build

You should see a successful compilation with no errors. You may see unused warnings for DeliveryEngine since nothing calls it yet.

Seeing a compilation error?

  • If you see an unresolved import error for crate::providers::registry, make sure your providers module and its registry submodule are declared as pub in main.rs.
  • If Notification can't be found, check that notifyhub-types is listed as a dependency in notifyhub-server/Cargo.toml and that Notification is marked pub in notifyhub-types/src/lib.rs.
  • If you see a type mismatch on query_scalar, make sure the variable is typed as i64 (the standard type for SQL COUNT results).

Ask for help: Why won't my delivery.rs compile in notifyhub-server?

✔️ Awesome, I've got everything!

Great. Double check you have saved delivery.rs and your module declaration file.

ⓧ I'd like to double check the full code

use std::sync::Arc;
use sqlx::SqlitePool;
use tokio::time::{sleep, Duration};

use crate::providers::registry::ProviderRegistry;
use notifyhub_types::Notification;

// Holds the database pool and provider registry
pub struct DeliveryEngine {
    pool: SqlitePool,
    registry: Arc<ProviderRegistry>,
}

impl DeliveryEngine {
    // Create a new engine with database and provider access
    pub fn new(pool: SqlitePool, registry: Arc<ProviderRegistry>) -> Self {
        Self { pool, registry }
    }

    // Spawn a background task that polls for queued notifications every second
    pub fn start_worker(&self) {
        let pool = self.pool.clone();
        let registry = self.registry.clone();

        tokio::spawn(async move {
            loop {
                // Query all notifications waiting to be delivered
                let notifications = sqlx::query_as::<_, Notification>(
                    "SELECT * FROM notifications WHERE status = 'queued'"
                )
                .fetch_all(&pool)
                .await;

                if let Ok(notifications) = notifications {
                    for notification in notifications {
                        Self::attempt_delivery(&pool, &registry, &notification).await;
                    }
                }

                // Wait 1 second before polling again
                sleep(Duration::from_secs(1)).await;
            }
        });
    }

    // Attempt to deliver a single notification with exponential backoff
    async fn attempt_delivery(
        pool: &SqlitePool,
        registry: &Arc<ProviderRegistry>,
        notification: &Notification,
    ) {
        // Count how many times we've already tried this notification
        let attempt_count: i64 = sqlx::query_scalar(
            "SELECT COUNT(*) FROM delivery_attempts WHERE notification_id = ?"
        )
        .bind(notification.id.to_string())
        .fetch_one(pool)
        .await
        .unwrap_or(0);

        let attempt_number = attempt_count + 1;

        // Look up the provider for this notification's channel
        let provider = match registry.get(&notification.channel) {
            Some(p) => p,
            None => {
                // No provider found for this channel, mark as failed
                sqlx::query("UPDATE notifications SET status = 'failed' WHERE id = ?")
                    .bind(notification.id.to_string())
                    .execute(pool)
                    .await
                    .ok();
                return;
            }
        };

        // Try to send the notification through the provider
        match provider.send(notification).await {
            Ok(_) => {
                // Log successful delivery attempt
                sqlx::query(
                    "INSERT INTO delivery_attempts (notification_id, attempt_number, status, error_message) VALUES (?, ?, 'success', NULL)"
                )
                .bind(notification.id.to_string())
                .bind(attempt_number)
                .execute(pool)
                .await
                .ok();

                // Update notification status to sent
                sqlx::query("UPDATE notifications SET status = 'sent' WHERE id = ?")
                    .bind(notification.id.to_string())
                    .execute(pool)
                    .await
                    .ok();
            }
            Err(e) => {
                // Log failed delivery attempt
                sqlx::query(
                    "INSERT INTO delivery_attempts (notification_id, attempt_number, status, error_message) VALUES (?, ?, 'failed', ?)"
                )
                .bind(notification.id.to_string())
                .bind(attempt_number)
                .bind(e.to_string())
                .execute(pool)
                .await
                .ok();

                if attempt_number >= 3 {
                    // Max retries exhausted, mark notification as permanently failed
                    sqlx::query("UPDATE notifications SET status = 'failed' WHERE id = ?")
                        .bind(notification.id.to_string())
                        .execute(pool)
                        .await
                        .ok();
                } else {
                    // Exponential backoff: 2^attempt_count seconds (1s, 2s, 4s)
                    let delay = Duration::from_secs(2u64.pow(attempt_count as u32));
                    sleep(delay).await;
                }
            }
        }
    }
}

Your delivery engine is ready to process notifications in the background. Next up, you will build the REST API endpoints that accept notification requests and feed them into this engine.

Build the REST API with Axum

Your delivery engine can pick up queued notifications and retry failed ones. But right now, there's no way for external applications to actually submit notifications into the queue.

In this step, you'll build the REST API layer that sits in front of everything. Clients send HTTP requests, the API validates them, inserts notifications into the database, and the delivery engine picks them up automatically.

In this step, get ready to:
  • Create API endpoint handlers for sending and querying notifications.
  • Implement API key authentication middleware.
  • Wire up the router, database, providers, and delivery engine in your main function.
Create the API endpoint handlers

Each API endpoint needs a handler function that receives the HTTP request, interacts with the database, and returns a response. You'll create three handlers: one to send notifications, one to fetch a single notification by ID, and one to list all notifications.

  • Create a new file called handlers.rs inside notifyhub-server/src/.
  • Add the send notification handler by pasting the following code into handlers.rs:
use axum::{
    extract::{Path, State},
    http::StatusCode,
    Json,
};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use uuid::Uuid;

use notifyhub_types::Notification;

// Request body shape for sending a notification
#[derive(Deserialize)]
pub struct SendNotificationRequest {
    pub user_id: String,
    pub channel: String,
    pub payload: String,
}

// Response returned after creating a notification
#[derive(Serialize)]
pub struct SendNotificationResponse {
    pub id: String,
}

// POST /api/notifications/send
pub async fn send_notification(
    State(pool): State<SqlitePool>,
    Json(req): Json<SendNotificationRequest>,
) -> Result<(StatusCode, Json<SendNotificationResponse>), StatusCode> {
    // Generate a unique ID for this notification
    let id = Uuid::new_v4().to_string();

    // Insert the notification with status "queued" so the delivery engine picks it up
    sqlx::query(
        "INSERT INTO notifications (id, user_id, channel, payload, status, created_at) VALUES (?, ?, ?, ?, 'queued', datetime('now'))"
    )
    .bind(&id)
    .bind(&req.user_id)
    .bind(&req.channel)
    .bind(&req.payload)
    .execute(&pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok((StatusCode::CREATED, Json(SendNotificationResponse { id })))
}

What does this code do?

The SendNotificationRequest struct defines the JSON shape that clients must send. Axum automatically deserializes incoming JSON into this struct using serde.

The handler generates a UUID v4 for the notification, inserts it into the database with status = 'queued', and returns a 201 Created response with the new ID. The delivery engine's background worker will pick up this queued notification on its next loop.

  • Now add the two GET handlers below the send_notification function in the same file:
// GET /api/notifications/{id}
pub async fn get_notification(
    State(pool): State<SqlitePool>,
    Path(id): Path<String>,
) -> Result<Json<Notification>, StatusCode> {
    // Query the database for a notification matching this ID
    let notification = sqlx::query_as::<_, Notification>(
        "SELECT id, user_id, channel, payload, status, created_at FROM notifications WHERE id = ?"
    )
    .bind(&id)
    .fetch_optional(&pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    // Return 404 if the notification doesn't exist
    match notification {
        Some(n) => Ok(Json(n)),
        None => Err(StatusCode::NOT_FOUND),
    }
}

// GET /api/notifications
pub async fn list_notifications(
    State(pool): State<SqlitePool>,
) -> Result<Json<Vec<Notification>>, StatusCode> {
    // Fetch all notifications ordered by newest first
    let notifications = sqlx::query_as::<_, Notification>(
        "SELECT id, user_id, channel, payload, status, created_at FROM notifications ORDER BY created_at DESC"
    )
    .fetch_all(&pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(notifications))
}

What do these handlers do?

The get_notification handler extracts the id from the URL path using Axum's Path extractor. It queries the database and returns a 404 if no matching notification is found.

The list_notifications handler returns all notifications sorted by creation date (newest first). Both use sqlx::query_as to automatically map database rows into Notification structs.

  • Save handlers.rs.

Seeing a compile error about Notification?

Make sure your Notification struct in notifyhub-types/src/lib.rs derives sqlx::FromRow and Serialize. Both are needed for the handlers to work.

help me fix Notification struct derive errors in my handlers

Implement API key authentication middleware

Any production API needs to verify that the caller is authorized. You'll implement middleware that intercepts every request, extracts an API key from the X-API-Key header, hashes it with SHA-256, and checks it against the api_keys table.

  • Add the sha2 crate to your server's dependencies by running this command from your workspace root:
cargo add sha2 --package notifyhub-server
  • Create a new file called middleware.rs inside notifyhub-server/src/.
  • Add the authentication middleware by pasting the following code into middleware.rs:
use axum::{
    extract::{Request, State},
    http::StatusCode,
    middleware::Next,
    response::Response,
};
use sha2::{Digest, Sha256};
use sqlx::SqlitePool;

// Middleware that validates the X-API-Key header against stored key hashes
pub async fn auth_middleware(
    State(pool): State<SqlitePool>,
    request: Request,
    next: Next,
) -> Result<Response, StatusCode> {
    // Extract the API key from the X-API-Key header
    let api_key = request
        .headers()
        .get("X-API-Key")
        .and_then(|v| v.to_str().ok())
        .ok_or(StatusCode::UNAUTHORIZED)?;

    // Hash the provided key with SHA-256
    let mut hasher = Sha256::new();
    hasher.update(api_key.as_bytes());
    let key_hash = format!("{:x}", hasher.finalize());

    // Check if a matching hash exists in the api_keys table
    let count = sqlx::query_scalar::<_, i64>(
        "SELECT COUNT(*) FROM api_keys WHERE key_hash = ?"
    )
    .bind(&key_hash)
    .fetch_one(&pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    // Reject with 401 if no matching key is found
    if count == 0 {
        return Err(StatusCode::UNAUTHORIZED);
    }

    // Key is valid, continue to the actual handler
    Ok(next.run(request).await)
}

Why hash the API key?

Storing raw API keys in the database is a security risk. If the database is compromised, all keys are exposed. By storing only the SHA-256 hash, you can verify keys without ever storing the plaintext.

The middleware uses Axum's from_fn_with_state pattern, which passes the database pool to the middleware function as state. This lets the middleware query the api_keys table directly.

  • Save middleware.rs.

Compile error about Request type?

Make sure you're importing Request from axum::extract::Request, not from axum::http::Request. In Axum 0.8.x, axum::extract::Request is the correct type for middleware functions.

help me fix the Request import in my Axum middleware

Wire up the router and delivery engine

The final piece is the main.rs file that ties everything together. It initializes the database, registers providers, starts the delivery engine worker, and launches the Axum server with authentication middleware applied to all routes.

  • Replace the contents of notifyhub-server/src/main.rs with the following code:
use axum::routing::{get, post};
use axum::Router;
use std::sync::Arc;
use tokio::net::TcpListener;

mod db;
mod delivery;
mod handlers;
mod middleware;
mod providers;

use db::init_db;
use delivery::DeliveryEngine;
use providers::console::ConsoleProvider;
use providers::registry::ProviderRegistry;
use providers::webhook::WebhookProvider;

#[tokio::main]
async fn main() {
    // Initialize the SQLite database and run migrations
    let pool = init_db().await.expect("Failed to initialize database");

    // Register notification providers for each channel
    let mut registry = ProviderRegistry::new();
    registry.register("console", Box::new(ConsoleProvider));
    registry.register("webhook", Box::new(WebhookProvider::new()));
    let registry = Arc::new(registry);

    // Start the background delivery engine worker
    let engine = DeliveryEngine::new(pool.clone(), registry.clone());
    engine.start_worker();

    // Build the API router with authentication middleware
    let app = Router::new()
        .route("/api/notifications/send", post(handlers::send_notification))
        .route("/api/notifications/{id}", get(handlers::get_notification))
        .route("/api/notifications", get(handlers::list_notifications))
        .route_layer(axum::middleware::from_fn_with_state(
            pool.clone(),
            middleware::auth_middleware,
        ))
        .with_state(pool);

    // Bind to port 3001 and start serving requests
    let listener = TcpListener::bind("0.0.0.0:3001").await.unwrap();
    println!("NotifyHub server running on http://localhost:3001");
    axum::serve(listener, app).await.unwrap();
}

What's happening here?

The main function follows a clear startup sequence: initialize database, register providers, start the background worker, build the router, then serve.

The .route_layer() call applies the authentication middleware to all routes above it. Every request must include a valid X-API-Key header before reaching any handler.

Notice the route path uses /{id} syntax. Axum 0.8.x requires this format for path parameters. The older /:id syntax will panic at runtime.

  • Save main.rs.
  • Verify the server compiles by running:
cargo build

You should see a successful compilation with no errors. Warnings about unused variables are fine for now.

Getting unresolved import errors?

Check that your mod declarations in main.rs match your file names exactly. Each .rs file in src/ needs a corresponding mod line. For the providers directory, make sure providers/mod.rs exists and re-exports its submodules.

help me debug unresolved import errors in my Axum notification server

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files: handlers.rs, middleware.rs, and main.rs.

ⓧ I'd like to double check the full code

Here is the full code for each file you created or modified in this step.

use axum::{
    extract::{Path, State},
    http::StatusCode,
    Json,
};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use uuid::Uuid;

use notifyhub_types::Notification;

// Request body shape for sending a notification
#[derive(Deserialize)]
pub struct SendNotificationRequest {
    pub user_id: String,
    pub channel: String,
    pub payload: String,
}

// Response returned after creating a notification
#[derive(Serialize)]
pub struct SendNotificationResponse {
    pub id: String,
}

// POST /api/notifications/send
pub async fn send_notification(
    State(pool): State<SqlitePool>,
    Json(req): Json<SendNotificationRequest>,
) -> Result<(StatusCode, Json<SendNotificationResponse>), StatusCode> {
    // Generate a unique ID for this notification
    let id = Uuid::new_v4().to_string();

    // Insert the notification with status "queued" so the delivery engine picks it up
    sqlx::query(
        "INSERT INTO notifications (id, user_id, channel, payload, status, created_at) VALUES (?, ?, ?, ?, 'queued', datetime('now'))"
    )
    .bind(&id)
    .bind(&req.user_id)
    .bind(&req.channel)
    .bind(&req.payload)
    .execute(&pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok((StatusCode::CREATED, Json(SendNotificationResponse { id })))
}

// GET /api/notifications/{id}
pub async fn get_notification(
    State(pool): State<SqlitePool>,
    Path(id): Path<String>,
) -> Result<Json<Notification>, StatusCode> {
    // Query the database for a notification matching this ID
    let notification = sqlx::query_as::<_, Notification>(
        "SELECT id, user_id, channel, payload, status, created_at FROM notifications WHERE id = ?"
    )
    .bind(&id)
    .fetch_optional(&pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    // Return 404 if the notification doesn't exist
    match notification {
        Some(n) => Ok(Json(n)),
        None => Err(StatusCode::NOT_FOUND),
    }
}

// GET /api/notifications
pub async fn list_notifications(
    State(pool): State<SqlitePool>,
) -> Result<Json<Vec<Notification>>, StatusCode> {
    // Fetch all notifications ordered by newest first
    let notifications = sqlx::query_as::<_, Notification>(
        "SELECT id, user_id, channel, payload, status, created_at FROM notifications ORDER BY created_at DESC"
    )
    .fetch_all(&pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(notifications))
}
use axum::{
    extract::{Request, State},
    http::StatusCode,
    middleware::Next,
    response::Response,
};
use sha2::{Digest, Sha256};
use sqlx::SqlitePool;

// Middleware that validates the X-API-Key header against stored key hashes
pub async fn auth_middleware(
    State(pool): State<SqlitePool>,
    request: Request,
    next: Next,
) -> Result<Response, StatusCode> {
    // Extract the API key from the X-API-Key header
    let api_key = request
        .headers()
        .get("X-API-Key")
        .and_then(|v| v.to_str().ok())
        .ok_or(StatusCode::UNAUTHORIZED)?;

    // Hash the provided key with SHA-256
    let mut hasher = Sha256::new();
    hasher.update(api_key.as_bytes());
    let key_hash = format!("{:x}", hasher.finalize());

    // Check if a matching hash exists in the api_keys table
    let count = sqlx::query_scalar::<_, i64>(
        "SELECT COUNT(*) FROM api_keys WHERE key_hash = ?"
    )
    .bind(&key_hash)
    .fetch_one(&pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    // Reject with 401 if no matching key is found
    if count == 0 {
        return Err(StatusCode::UNAUTHORIZED);
    }

    // Key is valid, continue to the actual handler
    Ok(next.run(request).await)
}
use axum::routing::{get, post};
use axum::Router;
use std::sync::Arc;
use tokio::net::TcpListener;

mod db;
mod delivery;
mod handlers;
mod middleware;
mod providers;

use db::init_db;
use delivery::DeliveryEngine;
use providers::console::ConsoleProvider;
use providers::registry::ProviderRegistry;
use providers::webhook::WebhookProvider;

#[tokio::main]
async fn main() {
    // Initialize the SQLite database and run migrations
    let pool = init_db().await.expect("Failed to initialize database");

    // Register notification providers for each channel
    let mut registry = ProviderRegistry::new();
    registry.register("console", Box::new(ConsoleProvider));
    registry.register("webhook", Box::new(WebhookProvider::new()));
    let registry = Arc::new(registry);

    // Start the background delivery engine worker
    let engine = DeliveryEngine::new(pool.clone(), registry.clone());
    engine.start_worker();

    // Build the API router with authentication middleware
    let app = Router::new()
        .route("/api/notifications/send", post(handlers::send_notification))
        .route("/api/notifications/{id}", get(handlers::get_notification))
        .route("/api/notifications", get(handlers::list_notifications))
        .route_layer(axum::middleware::from_fn_with_state(
            pool.clone(),
            middleware::auth_middleware,
        ))
        .with_state(pool);

    // Bind to port 3001 and start serving requests
    let listener = TcpListener::bind("0.0.0.0:3001").await.unwrap();
    println!("NotifyHub server running on http://localhost:3001");
    axum::serve(listener, app).await.unwrap();
}

Your REST API is live. Any client with a valid API key can now send notifications through a single HTTP call, and the delivery engine handles everything from there. Next up, you'll build the Rust SDK crate that wraps these endpoints into a clean developer-facing library.

Build the Rust SDK Crate

Your REST API is live and accepting authenticated requests. Now it is time to make the API easy for other Rust developers to consume.

Instead of forcing every consumer to hand-roll HTTP calls and error handling, you will create an SDK crate that wraps all the network details behind a clean, typed interface. The notifyhub-rs crate gives callers a single NotifyHubClient struct with methods like send_notification and get_status.

In this step, get ready to:
  • Define a custom error enum using thiserror.
  • Create the NotifyHubClient struct with a constructor.
  • Implement SDK methods that call the API with reqwest.
Define the error enum with thiserror

Every SDK needs a clear way to communicate what went wrong. The thiserror crate generates Display and From implementations automatically from a single derive macro, so callers can use the ? operator without boilerplate.

  • Open notifyhub-rs/src/lib.rs in your editor.
  • Replace any placeholder content with the following imports and error enum:
use reqwest;
use serde_json::Value;
use thiserror::Error;
use notifyhub_types::Notification;

// Custom error types for SDK operations
#[derive(Debug, Error)]
pub enum NotifyHubError {
    #[error("HTTP error: {0}")]
    HttpError(#[from] reqwest::Error),

    #[error("Deserialization error: {0}")]
    DeserializationError(#[from] serde_json::Error),

    #[error("API error: {0}")]
    ApiError(String),
}

What does this code do?

The #[derive(Error)] macro from thiserror generates the std::error::Error trait implementation plus a Display impl from each #[error("...")] annotation.

The #[from] attribute generates a From conversion, so reqwest::Error and serde_json::Error are automatically wrapped when you use the ? operator. The ApiError variant is for non-200 HTTP responses where the server returns an error message as text.

You won't see a change yet. The next chunk builds on top of this error enum.

Create the NotifyHubClient struct and constructor

The client struct holds everything needed to make authenticated API calls: the API key, the server's base URL, and a shared reqwest HTTP client that reuses connections.

  • Add the following struct and new() constructor directly below the NotifyHubError enum in notifyhub-rs/src/lib.rs:
// The main SDK client that wraps HTTP calls to the NotifyHub API
pub struct NotifyHubClient {
    api_key: String,
    base_url: String,
    http_client: reqwest::Client,
}

impl NotifyHubClient {
    // Create a new client instance with API key and server URL
    pub fn new(api_key: &str, base_url: &str) -> Self {
        Self {
            api_key: api_key.to_string(),
            base_url: base_url.to_string(),
            http_client: reqwest::Client::new(),
        }
    }
}

What does this code do?

The NotifyHubClient struct stores the API key and base URL as owned Strings. It also holds a reqwest::Client instance that manages an internal connection pool for efficient HTTP reuse.

The new() constructor accepts borrowed string slices (&str) so callers can pass both string literals and owned Strings without explicit conversion.

You won't see a change yet. The next chunk adds the actual API methods.

Implement the API methods

Each method corresponds to one REST endpoint. They all attach the X-API-Key header for authentication and handle non-success status codes by returning an ApiError.

  • Inside the existing impl NotifyHubClient block (below new()), add the send_notification method:
    // Send a notification through the API
    pub async fn send_notification(
        &self,
        user_id: &str,
        channel: &str,
        payload: Value,
    ) -> Result<String, NotifyHubError> {
        let url = format!("{}/api/notifications/send", self.base_url);
        let body = serde_json::json!({
            "user_id": user_id,
            "channel": channel,
            "payload": payload,
        });

        let response = self.http_client
            .post(&url)
            .header("X-API-Key", &self.api_key)
            .json(&body)
            .send()
            .await?;

        if !response.status().is_success() {
            let error_text = response.text().await.unwrap_or_default();
            return Err(NotifyHubError::ApiError(error_text));
        }

        let result: serde_json::Value = response.json().await?;
        let id = result["id"]
            .as_str()
            .ok_or_else(|| NotifyHubError::ApiError("Missing id in response".to_string()))?
            .to_string();

        Ok(id)
    }

What does this code do?

The method builds a JSON body matching the server's SendNotificationRequest shape and POSTs it to /api/notifications/send. The .header("X-API-Key", &self.api_key) call attaches authentication.

If the response status is not a success, the method reads the error body and wraps it in ApiError. On success, it deserializes the JSON and extracts the notification id string.

  • Still inside the same impl NotifyHubClient block, add the get_status and list_notifications methods below send_notification:
    // Get the delivery status of a specific notification
    pub async fn get_status(&self, id: &str) -> Result<Notification, NotifyHubError> {
        let url = format!("{}/api/notifications/{}", self.base_url, id);

        let response = self.http_client
            .get(&url)
            .header("X-API-Key", &self.api_key)
            .send()
            .await?;

        if !response.status().is_success() {
            let error_text = response.text().await.unwrap_or_default();
            return Err(NotifyHubError::ApiError(error_text));
        }

        let text = response.text().await?;
        let notification: Notification = serde_json::from_str(&text)?;
        Ok(notification)
    }

    // List all notifications from the API
    pub async fn list_notifications(&self) -> Result<Vec<Notification>, NotifyHubError> {
        let url = format!("{}/api/notifications", self.base_url);

        let response = self.http_client
            .get(&url)
            .header("X-API-Key", &self.api_key)
            .send()
            .await?;

        if !response.status().is_success() {
            let error_text = response.text().await.unwrap_or_default();
            return Err(NotifyHubError::ApiError(error_text));
        }

        let text = response.text().await?;
        let notifications: Vec<Notification> = serde_json::from_str(&text)?;
        Ok(notifications)
    }

What does this code do?

Both methods follow the same pattern: build the URL, attach the X-API-Key header, send a GET request, and check for non-success status.

The get_status method deserializes the response into a single Notification from the shared types crate. The list_notifications method deserializes into a Vec<Notification>. Both use serde_json::from_str which maps to the DeserializationError variant via #[from] if the JSON shape does not match.

  • Save notifyhub-rs/src/lib.rs.
  • Verify the crate compiles by running:
cargo build -p notifyhub-rs
  • You should see a successful compilation with no errors.

Seeing compilation errors?

  • If you see an error about Notification not being found, make sure notifyhub-types is listed as a path dependency in notifyhub-rs/Cargo.toml and that Notification is marked pub in notifyhub-types/src/lib.rs.
  • If you see unresolved import errors for serde_json or thiserror, check that both appear in [dependencies] of notifyhub-rs/Cargo.toml.
  • Make sure the Notification struct in the types crate derives serde::Deserialize.

Help me debug a compilation error in my notifyhub-rs SDK crate.

✔️ Awesome, I've got everything!

Great. Double check you have saved notifyhub-rs/src/lib.rs.

ⓧ I'd like to double check the full code

use reqwest;
use serde_json::Value;
use thiserror::Error;
use notifyhub_types::Notification;

// Custom error types for SDK operations
#[derive(Debug, Error)]
pub enum NotifyHubError {
    #[error("HTTP error: {0}")]
    HttpError(#[from] reqwest::Error),

    #[error("Deserialization error: {0}")]
    DeserializationError(#[from] serde_json::Error),

    #[error("API error: {0}")]
    ApiError(String),
}

// The main SDK client that wraps HTTP calls to the NotifyHub API
pub struct NotifyHubClient {
    api_key: String,
    base_url: String,
    http_client: reqwest::Client,
}

impl NotifyHubClient {
    // Create a new client instance with API key and server URL
    pub fn new(api_key: &str, base_url: &str) -> Self {
        Self {
            api_key: api_key.to_string(),
            base_url: base_url.to_string(),
            http_client: reqwest::Client::new(),
        }
    }

    // Send a notification through the API
    pub async fn send_notification(
        &self,
        user_id: &str,
        channel: &str,
        payload: Value,
    ) -> Result<String, NotifyHubError> {
        let url = format!("{}/api/notifications/send", self.base_url);
        let body = serde_json::json!({
            "user_id": user_id,
            "channel": channel,
            "payload": payload,
        });

        let response = self.http_client
            .post(&url)
            .header("X-API-Key", &self.api_key)
            .json(&body)
            .send()
            .await?;

        if !response.status().is_success() {
            let error_text = response.text().await.unwrap_or_default();
            return Err(NotifyHubError::ApiError(error_text));
        }

        let result: serde_json::Value = response.json().await?;
        let id = result["id"]
            .as_str()
            .ok_or_else(|| NotifyHubError::ApiError("Missing id in response".to_string()))?
            .to_string();

        Ok(id)
    }

    // Get the delivery status of a specific notification
    pub async fn get_status(&self, id: &str) -> Result<Notification, NotifyHubError> {
        let url = format!("{}/api/notifications/{}", self.base_url, id);

        let response = self.http_client
            .get(&url)
            .header("X-API-Key", &self.api_key)
            .send()
            .await?;

        if !response.status().is_success() {
            let error_text = response.text().await.unwrap_or_default();
            return Err(NotifyHubError::ApiError(error_text));
        }

        let text = response.text().await?;
        let notification: Notification = serde_json::from_str(&text)?;
        Ok(notification)
    }

    // List all notifications from the API
    pub async fn list_notifications(&self) -> Result<Vec<Notification>, NotifyHubError> {
        let url = format!("{}/api/notifications", self.base_url);

        let response = self.http_client
            .get(&url)
            .header("X-API-Key", &self.api_key)
            .send()
            .await?;

        if !response.status().is_success() {
            let error_text = response.text().await.unwrap_or_default();
            return Err(NotifyHubError::ApiError(error_text));
        }

        let text = response.text().await?;
        let notifications: Vec<Notification> = serde_json::from_str(&text)?;
        Ok(notifications)
    }
}

Your Rust SDK is ready to publish. Any Rust developer can now add notifyhub-rs to their project and send notifications in a few lines of code. Next up, you will build the same convenience for Flutter developers with a Dart SDK.

Build the Dart SDK Package

Your Rust SDK gives backend developers a way to interact with NotifyHub. Now it's time to give Flutter developers the same power with a Dart SDK package.

The Dart SDK follows the same client pattern as the Rust crate. It wraps your REST API behind a clean class with typed methods so Flutter developers never have to think about raw HTTP calls or header management.

In this step, get ready to:
  • Create the Dart package structure with a NotifyHubClient class.
  • Implement sendNotification, getStatus, and listNotifications methods.
  • Use the http package for API calls with typed response models.
Define the notification models and exception class

Every SDK needs typed models so callers get autocomplete and compile-time safety instead of raw JSON maps. You'll also define a custom exception for non-success responses.

  • Create the lib/src/ directory inside your notifyhub_dart folder by running these commands:
cd notifyhub_dart
mkdir -p lib/src
  • Create a new file lib/src/models.dart and add the following code:
/// Represents a notification returned from the API
class NotificationModel {
  final String id;
  final String userId;
  final String channel;
  final Map<String, dynamic> payload;
  final String status;
  final String createdAt;

  NotificationModel({
    required this.id,
    required this.userId,
    required this.channel,
    required this.payload,
    required this.status,
    required this.createdAt,
  });

  /// Parse a notification from a JSON map
  factory NotificationModel.fromJson(Map<String, dynamic> json) {
    return NotificationModel(
      id: json['id'] as String,
      userId: json['user_id'] as String,
      channel: json['channel'] as String,
      payload: json['payload'] as Map<String, dynamic>,
      status: json['status'] as String,
      createdAt: json['created_at'] as String,
    );
  }
}

/// Exception thrown when the NotifyHub API returns a non-2xx response
class NotifyHubException implements Exception {
  final int statusCode;
  final String message;

  NotifyHubException(this.statusCode, this.message);

  @override
  String toString() => 'NotifyHubException($statusCode): $message';
}

What does this code do?

NotificationModel mirrors the JSON shape your Rust API returns. The fromJson factory constructor handles deserialization so callers never touch raw maps.

NotifyHubException implements Dart's Exception interface. It carries the HTTP status code and response body, giving callers enough context to handle errors programmatically.

  • Save lib/src/models.dart.

Seeing a syntax error?

Make sure every field in NotificationModel has required in the constructor. Missing it causes a compile error in null-safe Dart.

help me fix a Dart model class syntax error in my NotifyHub SDK

Build the NotifyHubClient class

The client class is the public interface Flutter developers interact with. It handles HTTP calls, header injection, and response parsing.

  • Create lib/src/client.dart and add the following code:
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'models.dart';

/// Client for interacting with the NotifyHub API
class NotifyHubClient {
  final String apiKey;
  final String baseUrl;
  final http.Client _httpClient;

  /// Creates a new client with the given API key and base URL
  NotifyHubClient(this.apiKey, this.baseUrl) : _httpClient = http.Client();

  /// Sends a notification and returns the notification ID
  Future<String> sendNotification(
    String userId,
    String channel,
    Map<String, dynamic> payload,
  ) async {
    final response = await _httpClient.post(
      Uri.parse('$baseUrl/api/notifications/send'),
      headers: {
        'Content-Type': 'application/json',
        'X-API-Key': apiKey,
      },
      body: jsonEncode({
        'user_id': userId,
        'channel': channel,
        'payload': payload,
      }),
    );

    // Throw if the server returned an error
    if (response.statusCode < 200 || response.statusCode >= 300) {
      throw NotifyHubException(response.statusCode, response.body);
    }

    final data = jsonDecode(response.body) as Map<String, dynamic>;
    return data['id'] as String;
  }

  /// Gets the status of a notification by ID
  Future<NotificationModel> getStatus(String id) async {
    final response = await _httpClient.get(
      Uri.parse('$baseUrl/api/notifications/$id'),
      headers: {
        'X-API-Key': apiKey,
      },
    );

    if (response.statusCode < 200 || response.statusCode >= 300) {
      throw NotifyHubException(response.statusCode, response.body);
    }

    final data = jsonDecode(response.body) as Map<String, dynamic>;
    return NotificationModel.fromJson(data);
  }

  /// Lists all notifications
  Future<List<NotificationModel>> listNotifications() async {
    final response = await _httpClient.get(
      Uri.parse('$baseUrl/api/notifications'),
      headers: {
        'X-API-Key': apiKey,
      },
    );

    if (response.statusCode < 200 || response.statusCode >= 300) {
      throw NotifyHubException(response.statusCode, response.body);
    }

    final data = jsonDecode(response.body) as List<dynamic>;
    return data
        .map((item) => NotificationModel.fromJson(item as Map<String, dynamic>))
        .toList();
  }
}

What does this code do?

The constructor takes an apiKey and baseUrl, then creates an internal http.Client instance. Every method attaches the X-API-Key header automatically so callers never forget authentication.

sendNotification POSTs to your API and returns just the notification ID. getStatus and listNotifications deserialize responses into typed NotificationModel objects using the fromJson factory.

All three methods check the status code before parsing. If the API returns a non-2xx response, they throw a NotifyHubException with the status code and body so the caller can handle it.

  • Save lib/src/client.dart.

Import not resolving?

The import 'models.dart' line uses a relative import because both files live in the same lib/src/ directory. If you accidentally placed models.dart in lib/ instead of lib/src/, the import will fail.

help me fix Dart import resolution in my NotifyHub SDK

Wire up the package exports and verify

Dart packages expose their public API through a barrel file at lib/notifyhub_dart.dart. This is what consumers import when they depend on your package.

  • Create lib/notifyhub_dart.dart and add the following code:
/// NotifyHub Dart SDK for sending notifications through the NotifyHub API
library notifyhub_dart;

export 'src/client.dart';
export 'src/models.dart';

What does this file do?

The library directive declares this file as the package's entry point. The export statements make NotifyHubClient and NotificationModel available to anyone who imports package:notifyhub_dart/notifyhub_dart.dart.

Without these exports, the implementation files inside lib/src/ would be private to the package. This is the standard Dart convention for separating public API from internal implementation.

  • Save lib/notifyhub_dart.dart.
  • Verify the package compiles and all dependencies resolve by running:
dart pub get

You should see output showing the http dependency resolved successfully, with no errors.

dart pub get failing?

Check that your pubspec.yaml has http: ^1.0.0 under dependencies. Also confirm you're running the command from inside the notifyhub_dart directory, not the workspace root.

help me troubleshoot dart pub get failing in my NotifyHub Dart SDK

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files.

ⓧ I'd like to double check the full code

Here is the complete code for each file in the Dart SDK:

/// Represents a notification returned from the API
class NotificationModel {
  final String id;
  final String userId;
  final String channel;
  final Map<String, dynamic> payload;
  final String status;
  final String createdAt;

  NotificationModel({
    required this.id,
    required this.userId,
    required this.channel,
    required this.payload,
    required this.status,
    required this.createdAt,
  });

  /// Parse a notification from a JSON map
  factory NotificationModel.fromJson(Map<String, dynamic> json) {
    return NotificationModel(
      id: json['id'] as String,
      userId: json['user_id'] as String,
      channel: json['channel'] as String,
      payload: json['payload'] as Map<String, dynamic>,
      status: json['status'] as String,
      createdAt: json['created_at'] as String,
    );
  }
}

/// Exception thrown when the NotifyHub API returns a non-2xx response
class NotifyHubException implements Exception {
  final int statusCode;
  final String message;

  NotifyHubException(this.statusCode, this.message);

  @override
  String toString() => 'NotifyHubException($statusCode): $message';
}
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'models.dart';

/// Client for interacting with the NotifyHub API
class NotifyHubClient {
  final String apiKey;
  final String baseUrl;
  final http.Client _httpClient;

  /// Creates a new client with the given API key and base URL
  NotifyHubClient(this.apiKey, this.baseUrl) : _httpClient = http.Client();

  /// Sends a notification and returns the notification ID
  Future<String> sendNotification(
    String userId,
    String channel,
    Map<String, dynamic> payload,
  ) async {
    final response = await _httpClient.post(
      Uri.parse('$baseUrl/api/notifications/send'),
      headers: {
        'Content-Type': 'application/json',
        'X-API-Key': apiKey,
      },
      body: jsonEncode({
        'user_id': userId,
        'channel': channel,
        'payload': payload,
      }),
    );

    // Throw if the server returned an error
    if (response.statusCode < 200 || response.statusCode >= 300) {
      throw NotifyHubException(response.statusCode, response.body);
    }

    final data = jsonDecode(response.body) as Map<String, dynamic>;
    return data['id'] as String;
  }

  /// Gets the status of a notification by ID
  Future<NotificationModel> getStatus(String id) async {
    final response = await _httpClient.get(
      Uri.parse('$baseUrl/api/notifications/$id'),
      headers: {
        'X-API-Key': apiKey,
      },
    );

    if (response.statusCode < 200 || response.statusCode >= 300) {
      throw NotifyHubException(response.statusCode, response.body);
    }

    final data = jsonDecode(response.body) as Map<String, dynamic>;
    return NotificationModel.fromJson(data);
  }

  /// Lists all notifications
  Future<List<NotificationModel>> listNotifications() async {
    final response = await _httpClient.get(
      Uri.parse('$baseUrl/api/notifications'),
      headers: {
        'X-API-Key': apiKey,
      },
    );

    if (response.statusCode < 200 || response.statusCode >= 300) {
      throw NotifyHubException(response.statusCode, response.body);
    }

    final data = jsonDecode(response.body) as List<dynamic>;
    return data
        .map((item) => NotificationModel.fromJson(item as Map<String, dynamic>))
        .toList();
  }
}
/// NotifyHub Dart SDK for sending notifications through the NotifyHub API
library notifyhub_dart;

export 'src/client.dart';
export 'src/models.dart';

Your Dart SDK is ready for Flutter developers to use. Next up, you'll build the Next.js dashboard to visualize delivery logs and send test notifications from a web UI.

Build the Next.js Dashboard

Your Rust backend is serving notifications, and both SDKs can talk to it. But developers and ops teams need a visual way to monitor what's happening.

In this step, you'll build a Next.js dashboard that displays all notifications in a status-coded table and lets you send test notifications through a form. The dashboard connects directly to your Axum backend running on port 3001.

In this step, get ready to:
  • Create an API helper layer that handles authentication and HTTP calls to the backend.
  • Build a notifications table component with color-coded delivery status badges.
  • Add a send notification form and assemble the full dashboard page.
Create the API helper functions

Before building any UI components, you need a shared module that handles communication with the Rust backend. This keeps your API base URL, authentication header, and fetch logic in one place.

  • In your terminal, create the lib and components directories inside the dashboard/app/ folder by running these commands:
mkdir -p dashboard/app/lib dashboard/app/components
  • Confirm the directories exist by running:
ls dashboard/app/

You should see lib and components listed alongside the existing page.tsx and layout.tsx files.

  • Create a new file at dashboard/app/lib/api.ts and add the following code:
// Base URL for the NotifyHub backend
const API_BASE_URL = 'http://localhost:3001';

// API key for authentication with the backend
const API_KEY = 'test-api-key-123';

// Fetch all notifications from the backend
export async function fetchNotifications() {
  const response = await fetch(`${API_BASE_URL}/api/notifications`, {
    headers: {
      'X-API-Key': API_KEY,
    },
  });

  if (!response.ok) {
    throw new Error(`Failed to fetch notifications: ${response.statusText}`);
  }

  return response.json();
}

// Send a new notification through the backend
export async function sendNotification(data: {
  user_id: string;
  channel: string;
  payload: Record<string, string>;
}) {
  const response = await fetch(`${API_BASE_URL}/api/notifications/send`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-API-Key': API_KEY,
    },
    body: JSON.stringify(data),
  });

  if (!response.ok) {
    throw new Error(`Failed to send notification: ${response.statusText}`);
  }

  return response.json();
}

What does this code do?

This module centralizes all backend communication for the dashboard.

  • API_BASE_URL points to your Axum server on port 3001. The dashboard runs on port 3000, so this is a cross-origin request.
  • API_KEY is sent as the X-API-Key header on every request, matching the authentication middleware you built in the backend.
  • fetchNotifications() retrieves the full list of notifications. sendNotification() posts a new notification to the send endpoint.

You won't see a change yet. The next chunk uses this module to render notifications in a table.

✔️ Awesome, I've got everything!

Great. Make sure you've saved dashboard/app/lib/api.ts.

ⓧ I'd like to double check the full code

// Base URL for the NotifyHub backend
const API_BASE_URL = 'http://localhost:3001';

// API key for authentication with the backend
const API_KEY = 'test-api-key-123';

// Fetch all notifications from the backend
export async function fetchNotifications() {
  const response = await fetch(`${API_BASE_URL}/api/notifications`, {
    headers: {
      'X-API-Key': API_KEY,
    },
  });

  if (!response.ok) {
    throw new Error(`Failed to fetch notifications: ${response.statusText}`);
  }

  return response.json();
}

// Send a new notification through the backend
export async function sendNotification(data: {
  user_id: string;
  channel: string;
  payload: Record<string, string>;
}) {
  const response = await fetch(`${API_BASE_URL}/api/notifications/send`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-API-Key': API_KEY,
    },
    body: JSON.stringify(data),
  });

  if (!response.ok) {
    throw new Error(`Failed to send notification: ${response.statusText}`);
  }

  return response.json();
}
Build the notifications table component

The notifications table is the core of the dashboard. It shows every notification your system has processed, with color-coded badges so you can see at a glance which ones succeeded and which failed.

  • Create a new file at dashboard/app/components/NotificationTable.tsx and add the following code:
'use client';

// Color-coded status badge component
function StatusBadge({ status }: { status: string }) {
  const colors: Record<string, string> = {
    sent: 'bg-green-100 text-green-800',
    queued: 'bg-yellow-100 text-yellow-800',
    failed: 'bg-red-100 text-red-800',
  };

  return (
    <span
      className={`px-2 py-1 rounded-full text-xs font-medium ${colors[status] || 'bg-gray-100 text-gray-800'}`}
    >
      {status}
    </span>
  );
}

// Table displaying all notifications with status badges
export default function NotificationTable({ notifications }: { notifications: any[] }) {
  if (notifications.length === 0) {
    return (
      <p className="text-gray-500 text-center py-8">
        No notifications yet. Send one using the form below.
      </p>
    );
  }

  return (
    <div className="overflow-x-auto">
      <table className="min-w-full divide-y divide-gray-200">
        <thead className="bg-gray-50">
          <tr>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Channel</th>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created At</th>
          </tr>
        </thead>
        <tbody className="bg-white divide-y divide-gray-200">
          {notifications.map((notification) => (
            <tr key={notification.id}>
              <td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
                {notification.id.slice(0, 8)}...
              </td>
              <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
                {notification.channel}
              </td>
              <td className="px-6 py-4 whitespace-nowrap">
                <StatusBadge status={notification.status} />
              </td>
              <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
                {new Date(notification.created_at).toLocaleString()}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

What does this code do?

This is a client component (marked with 'use client') because it receives props from a parent that manages state.

  • StatusBadge renders a colored pill. Green for sent, yellow for queued, red for failed.
  • The table truncates the notification ID to 8 characters using .slice(0, 8) so UUIDs don't overflow the column.
  • If no notifications exist yet, the component shows a helpful empty-state message.
  • Save dashboard/app/components/NotificationTable.tsx.

You won't see this component in the browser yet. The next chunk builds the send form and wires both components into the page.

✔️ Awesome, I've got everything!

Great. Make sure your file is saved and move on to the next chunk.

ⓧ I'd like to double check the full code

'use client';

// Color-coded status badge component
function StatusBadge({ status }: { status: string }) {
  const colors: Record<string, string> = {
    sent: 'bg-green-100 text-green-800',
    queued: 'bg-yellow-100 text-yellow-800',
    failed: 'bg-red-100 text-red-800',
  };

  return (
    <span
      className={`px-2 py-1 rounded-full text-xs font-medium ${colors[status] || 'bg-gray-100 text-gray-800'}`}
    >
      {status}
    </span>
  );
}

// Table displaying all notifications with status badges
export default function NotificationTable({ notifications }: { notifications: any[] }) {
  if (notifications.length === 0) {
    return (
      <p className="text-gray-500 text-center py-8">
        No notifications yet. Send one using the form below.
      </p>
    );
  }

  return (
    <div className="overflow-x-auto">
      <table className="min-w-full divide-y divide-gray-200">
        <thead className="bg-gray-50">
          <tr>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Channel</th>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created At</th>
          </tr>
        </thead>
        <tbody className="bg-white divide-y divide-gray-200">
          {notifications.map((notification) => (
            <tr key={notification.id}>
              <td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
                {notification.id.slice(0, 8)}...
              </td>
              <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
                {notification.channel}
              </td>
              <td className="px-6 py-4 whitespace-nowrap">
                <StatusBadge status={notification.status} />
              </td>
              <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
                {new Date(notification.created_at).toLocaleString()}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
Add the Send Notification form and assemble the page

The send form lets you fire test notifications directly from the dashboard. It calls the same API endpoint your SDKs use, so you can verify the full pipeline visually.

  • Create a new file at dashboard/app/components/SendNotificationForm.tsx and add the following code:
'use client';

import { useState } from 'react';
import { sendNotification } from '../lib/api';

// Form for sending notifications through the API
export default function SendNotificationForm({ onSent }: { onSent: () => void }) {
  const [userId, setUserId] = useState('');
  const [channel, setChannel] = useState('console');
  const [payload, setPayload] = useState('{"message": "Hello from NotifyHub!"}');
  const [result, setResult] = useState<{ success: boolean; message: string } | null>(null);
  const [loading, setLoading] = useState(false);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setLoading(true);
    setResult(null);

    try {
      // Parse the JSON payload from the textarea
      const parsedPayload = JSON.parse(payload);

      const response = await sendNotification({
        user_id: userId,
        channel,
        payload: parsedPayload,
      });

      setResult({ success: true, message: `Notification sent! ID: ${response.id}` });
      onSent();
    } catch (error) {
      setResult({
        success: false,
        message: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
      });
    } finally {
      setLoading(false);
    }
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-4 bg-white p-6 rounded-lg shadow">
      <h2 className="text-lg font-semibold text-gray-900">Send Notification</h2>

      <div>
        <label htmlFor="userId" className="block text-sm font-medium text-gray-700">
          User ID
        </label>
        <input
          id="userId"
          type="text"
          value={userId}
          onChange={(e) => setUserId(e.target.value)}
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
          placeholder="user-123"
          required
        />
      </div>

      <div>
        <label htmlFor="channel" className="block text-sm font-medium text-gray-700">
          Channel
        </label>
        <select
          id="channel"
          value={channel}
          onChange={(e) => setChannel(e.target.value)}
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
        >
          <option value="console">Console</option>
          <option value="webhook">Webhook</option>
        </select>
      </div>

      <div>
        <label htmlFor="payload" className="block text-sm font-medium text-gray-700">
          Payload (JSON)
        </label>
        <textarea
          id="payload"
          value={payload}
          onChange={(e) => setPayload(e.target.value)}
          rows={3}
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm font-mono"
          placeholder='{"message": "Hello!"}'
          required
        />
      </div>

      <button
        type="submit"
        disabled={loading}
        className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50 text-sm font-medium"
      >
        {loading ? 'Sending...' : 'Send Notification'}
      </button>

      {result && (
        <div
          className={`p-3 rounded-md text-sm ${
            result.success ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'
          }`}
        >
          {result.message}
        </div>
      )}
    </form>
  );
}

What does this code do?

This client component manages form state and submits notifications to your backend.

  • The channel dropdown offers console and webhook options, matching the providers you built in the Rust backend.
  • The payload textarea accepts raw JSON. JSON.parse() validates it before sending.
  • The onSent callback triggers a notification list refresh after a successful send.
  • Success and error results display inline with green or red backgrounds.
  • Save dashboard/app/components/SendNotificationForm.tsx.

Now wire both components together in the main page.

  • Open the existing dashboard/app/page.tsx file and replace its contents with the following:
'use client';

import { useEffect, useState } from 'react';
import { fetchNotifications } from './lib/api';
import NotificationTable from './components/NotificationTable';
import SendNotificationForm from './components/SendNotificationForm';

// Home page displaying notification list and send form
export default function Home() {
  const [notifications, setNotifications] = useState<any[]>([]);
  const [error, setError] = useState<string | null>(null);

  // Load notifications from the backend
  async function loadNotifications() {
    try {
      const data = await fetchNotifications();
      setNotifications(data);
      setError(null);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to load notifications');
    }
  }

  // Fetch notifications on mount and poll every 5 seconds
  useEffect(() => {
    loadNotifications();
    const interval = setInterval(loadNotifications, 5000);
    return () => clearInterval(interval);
  }, []);

  return (
    <main className="min-h-screen bg-gray-100 p-8">
      <div className="max-w-4xl mx-auto space-y-8">
        <h1 className="text-3xl font-bold text-gray-900">NotifyHub Dashboard</h1>

        {error && (
          <div className="bg-red-50 text-red-800 p-4 rounded-md text-sm">
            {error}
          </div>
        )}

        <div className="bg-white rounded-lg shadow overflow-hidden">
          <div className="px-6 py-4 border-b border-gray-200">
            <h2 className="text-lg font-semibold text-gray-900">Notifications</h2>
          </div>
          <NotificationTable notifications={notifications} />
        </div>

        <SendNotificationForm onSent={loadNotifications} />
      </div>
    </main>
  );
}

What does this code do?

This is the home page of your dashboard. It orchestrates the two components and manages data fetching.

  • useEffect fetches notifications on mount, then polls every 5 seconds with setInterval. This keeps the table fresh without a manual refresh.
  • loadNotifications is passed as the onSent callback to the form, so sending a notification immediately refreshes the list.
  • Errors from the backend display in a red banner at the top of the page.
  • Save dashboard/app/page.tsx.
  • Make sure your Axum backend is still running on port 3001.
  • Start the Next.js dev server by running this command from the dashboard/ directory:
npm run dev
  • Open http://localhost:3000 in your browser.

You should see the NotifyHub Dashboard heading, an empty notifications table with the message "No notifications yet", and a Send Notification form below it.

  • Fill in the form with user-123 as the User ID, select console as the channel, and click Send Notification.

You should see a green success message with the notification ID. Within 5 seconds, the notification appears in the table above with a colored status badge.

Dashboard showing a connection error?

  • Make sure your Axum backend is running on port 3001. The dashboard connects to http://localhost:3001.
  • If you see a CORS error in the browser console, your backend needs CORS headers. Make sure your Axum server includes a CORS layer that allows requests from http://localhost:3000.
  • Check that the API_KEY value in api.ts matches a valid key in your api_keys database table.

Still stuck? Why can't my Next.js dashboard connect to my Axum backend on localhost:3001?

✔️ Awesome, I've got everything!

Great. Make sure all four files are saved. Your dashboard is live.

ⓧ I'd like to double check the full code

// Base URL for the NotifyHub backend
const API_BASE_URL = 'http://localhost:3001';

// API key for authentication with the backend
const API_KEY = 'test-api-key-123';

// Fetch all notifications from the backend
export async function fetchNotifications() {
  const response = await fetch(`${API_BASE_URL}/api/notifications`, {
    headers: {
      'X-API-Key': API_KEY,
    },
  });

  if (!response.ok) {
    throw new Error(`Failed to fetch notifications: ${response.statusText}`);
  }

  return response.json();
}

// Send a new notification through the backend
export async function sendNotification(data: {
  user_id: string;
  channel: string;
  payload: Record<string, string>;
}) {
  const response = await fetch(`${API_BASE_URL}/api/notifications/send`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-API-Key': API_KEY,
    },
    body: JSON.stringify(data),
  });

  if (!response.ok) {
    throw new Error(`Failed to send notification: ${response.statusText}`);
  }

  return response.json();
}
'use client';

// Color-coded status badge component
function StatusBadge({ status }: { status: string }) {
  const colors: Record<string, string> = {
    sent: 'bg-green-100 text-green-800',
    queued: 'bg-yellow-100 text-yellow-800',
    failed: 'bg-red-100 text-red-800',
  };

  return (
    <span
      className={`px-2 py-1 rounded-full text-xs font-medium ${colors[status] || 'bg-gray-100 text-gray-800'}`}
    >
      {status}
    </span>
  );
}

// Table displaying all notifications with status badges
export default function NotificationTable({ notifications }: { notifications: any[] }) {
  if (notifications.length === 0) {
    return (
      <p className="text-gray-500 text-center py-8">
        No notifications yet. Send one using the form below.
      </p>
    );
  }

  return (
    <div className="overflow-x-auto">
      <table className="min-w-full divide-y divide-gray-200">
        <thead className="bg-gray-50">
          <tr>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Channel</th>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created At</th>
          </tr>
        </thead>
        <tbody className="bg-white divide-y divide-gray-200">
          {notifications.map((notification) => (
            <tr key={notification.id}>
              <td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
                {notification.id.slice(0, 8)}...
              </td>
              <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
                {notification.channel}
              </td>
              <td className="px-6 py-4 whitespace-nowrap">
                <StatusBadge status={notification.status} />
              </td>
              <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
                {new Date(notification.created_at).toLocaleString()}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
'use client';

import { useState } from 'react';
import { sendNotification } from '../lib/api';

// Form for sending notifications through the API
export default function SendNotificationForm({ onSent }: { onSent: () => void }) {
  const [userId, setUserId] = useState('');
  const [channel, setChannel] = useState('console');
  const [payload, setPayload] = useState('{"message": "Hello from NotifyHub!"}');
  const [result, setResult] = useState<{ success: boolean; message: string } | null>(null);
  const [loading, setLoading] = useState(false);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setLoading(true);
    setResult(null);

    try {
      // Parse the JSON payload from the textarea
      const parsedPayload = JSON.parse(payload);

      const response = await sendNotification({
        user_id: userId,
        channel,
        payload: parsedPayload,
      });

      setResult({ success: true, message: `Notification sent! ID: ${response.id}` });
      onSent();
    } catch (error) {
      setResult({
        success: false,
        message: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
      });
    } finally {
      setLoading(false);
    }
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-4 bg-white p-6 rounded-lg shadow">
      <h2 className="text-lg font-semibold text-gray-900">Send Notification</h2>

      <div>
        <label htmlFor="userId" className="block text-sm font-medium text-gray-700">
          User ID
        </label>
        <input
          id="userId"
          type="text"
          value={userId}
          onChange={(e) => setUserId(e.target.value)}
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
          placeholder="user-123"
          required
        />
      </div>

      <div>
        <label htmlFor="channel" className="block text-sm font-medium text-gray-700">
          Channel
        </label>
        <select
          id="channel"
          value={channel}
          onChange={(e) => setChannel(e.target.value)}
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
        >
          <option value="console">Console</option>
          <option value="webhook">Webhook</option>
        </select>
      </div>

      <div>
        <label htmlFor="payload" className="block text-sm font-medium text-gray-700">
          Payload (JSON)
        </label>
        <textarea
          id="payload"
          value={payload}
          onChange={(e) => setPayload(e.target.value)}
          rows={3}
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm font-mono"
          placeholder='{"message": "Hello!"}'
          required
        />
      </div>

      <button
        type="submit"
        disabled={loading}
        className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50 text-sm font-medium"
      >
        {loading ? 'Sending...' : 'Send Notification'}
      </button>

      {result && (
        <div
          className={`p-3 rounded-md text-sm ${
            result.success ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'
          }`}
        >
          {result.message}
        </div>
      )}
    </form>
  );
}
'use client';

import { useEffect, useState } from 'react';
import { fetchNotifications } from './lib/api';
import NotificationTable from './components/NotificationTable';
import SendNotificationForm from './components/SendNotificationForm';

// Home page displaying notification list and send form
export default function Home() {
  const [notifications, setNotifications] = useState<any[]>([]);
  const [error, setError] = useState<string | null>(null);

  // Load notifications from the backend
  async function loadNotifications() {
    try {
      const data = await fetchNotifications();
      setNotifications(data);
      setError(null);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to load notifications');
    }
  }

  // Fetch notifications on mount and poll every 5 seconds
  useEffect(() => {
    loadNotifications();
    const interval = setInterval(loadNotifications, 5000);
    return () => clearInterval(interval);
  }, []);

  return (
    <main className="min-h-screen bg-gray-100 p-8">
      <div className="max-w-4xl mx-auto space-y-8">
        <h1 className="text-3xl font-bold text-gray-900">NotifyHub Dashboard</h1>

        {error && (
          <div className="bg-red-50 text-red-800 p-4 rounded-md text-sm">
            {error}
          </div>
        )}

        <div className="bg-white rounded-lg shadow overflow-hidden">
          <div className="px-6 py-4 border-b border-gray-200">
            <h2 className="text-lg font-semibold text-gray-900">Notifications</h2>
          </div>
          <NotificationTable notifications={notifications} />
        </div>

        <SendNotificationForm onSent={loadNotifications} />
      </div>
    </main>
  );
}

Why poll instead of WebSockets?

Polling every 5 seconds is simple to implement and good enough for a monitoring dashboard. For a high-traffic production system, you would upgrade to WebSockets or Server-Sent Events for instant updates.

The architecture already supports this. Your Axum backend could add a /api/notifications/stream endpoint using SSE, and the dashboard would subscribe instead of polling.

Secret mission

Add User Notification Preferences

Right now, your notification service delivers to every channel regardless of what the user wants. Real notification infrastructure (like Novu or Knock) lets users control which channels they receive on. Build a preferences system that respects user choices before delivering.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing cloud costs or subscriptions to worry about.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The notifyhub project folder on your Desktop (Cargo workspace, Next.js dashboard, Dart SDK package).
  • SQLite database file (notifyhub.db) inside the project folder.
  • Running processes: the Rust server and the Next.js dev server.

Keep everything running

No action needed. Choose this if you're still actively building or experimenting with NotifyHub.

  • Your Rust server and Next.js dashboard can keep running in their terminal windows.
  • Your SQLite database retains all notification logs and delivery attempts.
  • You can continue sending test notifications and building new providers.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your project files stay intact for when you return.

  • In the terminal running the Rust server, press Ctrl+C to stop it.
  • In the terminal running the Next.js dev server, press Ctrl+C to stop it.
  • Your project files and SQLite database remain on disk. When you're ready to resume, run cargo run in the notifyhub-server directory and npm run dev in the dashboard directory.

Delete - I don't want to use this again

Remove all project files and start fresh. This deletes everything you built in this project.

  • Stop any running processes first. Press Ctrl+C in each terminal running the Rust server or Next.js dev server.
  • Delete the entire project folder by running this command (macOS/Linux):
cd ~/Desktop
rm -rf notifyhub

On Windows, run this instead:

cd ~\Desktop
rmdir /s /q notifyhub
  • Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows) on your Desktop. You should no longer see notifyhub listed.

That's a Wrap!

That's a Wrap!

Nice work. You just built a complete notification infrastructure service from scratch. NotifyHub is a real, working system that accepts notification requests, queues them for async delivery with retries, and exposes SDKs for both Rust and Dart developers.

This is the exact architecture behind tools like Novu ($250/mo) and Knock ($250/mo). You built it for $0.

Here's what you accomplished:

  • Built an async Rust backend with Axum that queues notifications and delivers them through pluggable providers with exponential backoff retries and API key authentication.
  • Designed a trait-based provider system and published two SDKs: a Rust SDK crate (notifyhub-rs) and a Dart SDK package (notifyhub_dart) that developers can use to send notifications through your API.
  • Built a Next.js dashboard with the App Router that displays delivery logs and lets you send test notifications in real time.
  • Secret Mission: Added user notification preferences so subscribers can choose which channels they receive on, with full SDK and dashboard support.

Ready to quiz yourself?