Skip to main content
Multi-channel8 min read

Building Multi-Channel Notifications with One API

A complete architecture for routing product events to email, SMS, Slack, or Telegram based on user preferences — with Node.js implementation, webhook handling, fallback logic, suppression, and delivery analytics.

S
Senddio Team
Engineering · September 8, 2026

A product event — a payment succeeding, an order shipping, a threshold being crossed, a build failing — often needs to reach a user through more than one channel. Email for the audit trail, SMS if it is urgent, Slack if the user is a developer watching a pipeline, Telegram if they are running a trading bot that needs real-time alerts.

Building this with separate provider integrations means separate SDKs, separate credentials, separate webhook endpoints, separate log formats, and separate suppression lists. When something goes wrong and a user says they did not receive a notification, you have four different systems to check.

This post shows how to build a clean notification routing layer around Senddio's POST /v1/send — the same endpoint for all six channels. We will cover the preference model, the send loop, webhook handling, fallback logic, suppression, and delivery analytics.

The architecture

Product event
     │
     ▼
Notification service  ─── reads ──▶  User preferences (DB)
     │
     ├── email ──▶  POST /v1/send { channel: "email", ... }
     ├── sms   ──▶  POST /v1/send { channel: "sms",   ... }
     ├── slack ──▶  POST /v1/send { channel: "slack", ... }
     └── ...
            │
            ▼
       Senddio API ──▶ provider (Senddio email / Telnyx / Slack / etc.)
            │
            ▼
       Webhook ──▶ your webhook endpoint
            │
            ▼
      Store delivery status

Your application owns the routing decision. Senddio owns the delivery. The webhook closes the loop by feeding delivery outcomes back to your application.

User preference model

Store notification preferences in your database. A simple schema:

// users table (relevant fields)
{
  id: "usr_abc123",
  email: "user@example.com",
  phone: "+447911123456",
  slackUserId: "U01234ABCDE",
  telegramChatId: "987654321",
  notificationPreferences: {
    urgent: ["sms", "email"],
    standard: ["email"],
    developer_alerts: ["slack", "telegram"],
    marketing: []
  }
}

Each notification type maps to a priority tier. The tier maps to an ordered list of channels. Your notification service reads this when an event fires.

The notification service

const SENDDIO_KEY = process.env.SENDDIO_API_KEY;
const API = "https://api.senddio.com/v1/send";

async function sendNotification(userId, eventType, payload) {
  const user = await db.users.findById(userId);
  const channels = user.notificationPreferences[eventType] ?? ["email"];

  const results = [];

  for (const channel of channels) {
    try {
      const body = buildChannelPayload(channel, user, payload);
      const res = await fetch(API, {
        method: "POST",
        headers: {
          "Authorization": "Bearer " + SENDDIO_KEY,
          "Content-Type": "application/json"
        },
        body: JSON.stringify(body)
      });

      const data = await res.json();
      if (!res.ok) throw new Error(data.message ?? "Senddio rejected the request");

      await db.notificationAttempts.create({
        userId,
        eventType,
        channel,
        messageId: data.id,
        status: "queued"
      });

      results.push({ channel, messageId: data.id, status: "queued" });

      // For standard priority: first successful queue is enough
      // For urgent: loop through all channels (parallel sends — see below)
      if (payload.priority !== "urgent") break;

    } catch (err) {
      console.error("Send failed for channel " + channel + ":", err.message);
      // Continue to next channel
    }
  }

  return results;
}

Building channel payloads

function buildChannelPayload(channel, user, payload) {
  const tag = payload.eventType + "-" + payload.priority;

  if (channel === "email") {
    return {
      channel: "email",
      from: "notifications@yourdomain.com",
      to: user.email,
      subject: payload.subject,
      html: payload.html,
      text: payload.text,
      messageType: "transactional",
      tag
    };
  }

  if (channel === "sms") {
    return {
      channel: "sms",
      from: process.env.SMS_FROM,
      to: user.phone,
      body: payload.smsBody ?? stripHtml(payload.text),
      tag
    };
  }

  if (channel === "slack") {
    return {
      channel: "slack",
      to: user.slackUserId,
      body: payload.slackBody ?? payload.text,
      tag
    };
  }

  if (channel === "telegram") {
    return {
      channel: "telegram",
      to: user.telegramChatId,
      body: payload.text,
      tag
    };
  }

  if (channel === "whatsapp") {
    return {
      channel: "whatsapp",
      from: process.env.WHATSAPP_FROM,
      to: user.phone,
      body: payload.smsBody ?? stripHtml(payload.text),
      tag
    };
  }

  throw new Error("Unknown channel: " + channel);
}

Parallel sends for urgent events

For urgent notifications, you may want to send all channels simultaneously rather than sequentially. Use Promise.allSettled so a failure on one channel does not prevent the others from sending:

async function sendUrgent(userId, payload) {
  const user = await db.users.findById(userId);
  const channels = user.notificationPreferences.urgent ?? ["sms", "email"];

  const sends = channels.map(async (channel) => {
    const body = buildChannelPayload(channel, user, payload);
    const res = await fetch(API, {
      method: "POST",
      headers: {
        "Authorization": "Bearer " + SENDDIO_KEY,
        "Content-Type": "application/json"
      },
      body: JSON.stringify(body)
    });
    const data = await res.json();
    if (!res.ok) throw new Error(data.message ?? "Senddio rejected the request");
    return { channel, data };
  });

  const results = await Promise.allSettled(sends);

  for (const result of results) {
    if (result.status === "fulfilled") {
      await db.notificationAttempts.create({
        userId,
        channel: result.value.channel,
        messageId: result.value.data.id,
        status: "queued"
      });
    }
  }

  return results;
}

Handling delivery status via webhooks

The POST /v1/send response confirms the message entered the queue — it does not confirm delivery. Actual delivery outcomes arrive as webhook events.

Also check GET /v1/messages/:id for send failures that occur before delivery starts. Do not treat a queued response as successful delivery.

Configure a webhook endpoint in the Senddio dashboard and handle the events:

Register this raw-body route before any global express.json() middleware. Signature verification needs the exact bytes Senddio sent.

import { createHmac, timingSafeEqual } from "crypto";

app.post("/webhooks/senddio", express.raw({ type: "application/json" }), async (req, res) => {
  // Verify the signature
  const sig = req.headers["senddio-signature"];
  const expected = "sha256=" + createHmac("sha256", process.env.WEBHOOK_SECRET)
    .update(req.body)
    .digest("hex");

  if (typeof sig !== "string" || Buffer.byteLength(sig) !== Buffer.byteLength(expected) ||
      !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return res.status(401).send("Invalid signature");
  }

  const event = JSON.parse(req.body);

  switch (event.event) {
    case "message.delivered":
      await db.notificationAttempts.update(
        { messageId: event.data.messageId },
        { status: "delivered", deliveredAt: new Date(event.timestamp * 1000) }
      );
      break;

    case "message.bounced":
      await db.notificationAttempts.update(
        { messageId: event.data.messageId },
        { status: "bounced" }
      );
      // Consider triggering fallback channel here
      await triggerFallback(event.data.messageId);
      break;

    case "message.failed":
      await db.notificationAttempts.update(
        { messageId: event.data.messageId },
        { status: "failed" }
      );
      break;
  }

  res.status(200).end();
});

Fallback logic

When a primary channel fails — email bounces, SMS delivery fails — you can automatically fall back to the next channel in the user's preference list.

async function triggerFallback(messageId) {
  const attempt = await db.notificationAttempts.findOne({ messageId });
  if (!attempt) return;

  const user = await db.users.findById(attempt.userId);
  const channels = user.notificationPreferences[attempt.eventType] ?? [];

  // Find the next channel after the one that failed
  const failedIndex = channels.indexOf(attempt.channel);
  const nextChannel = channels[failedIndex + 1];

  if (!nextChannel) {
    console.warn("No fallback channel for user " + attempt.userId);
    return;
  }

  // Re-send via the next channel
  const payload = await db.notificationPayloads.findOne({ notificationId: attempt.notificationId });
  const body = buildChannelPayload(nextChannel, user, payload);

  const res = await fetch(API, {
    method: "POST",
    headers: {
      "Authorization": "Bearer " + SENDDIO_KEY,
      "Content-Type": "application/json"
    },
    body: JSON.stringify(body)
  });

  const data = await res.json();
  await db.notificationAttempts.create({
    userId: attempt.userId,
    eventType: attempt.eventType,
    channel: nextChannel,
    messageId: data.id,
    status: "queued",
    isFallback: true
  });
}

Suppression handling

Senddio automatically adds hard-bounced email addresses and failed phone numbers to your organisation's suppression list. Future sends to suppressed contacts are rejected at the API level with a 400 and error code SUPPRESSED_RECIPIENT.

Handle this in your send loop:

const res = await fetch(API, { method: "POST", headers, body: JSON.stringify(body) });

if (res.status === 400) {
  const err = await res.json();
  if (err.code === "SUPPRESSED_RECIPIENT") {
    // Update the user record so we stop trying this channel
    await db.users.update(
      { id: userId },
      { ["suppressedChannels." + channel]: true }
    );
    // Continue to next channel
    continue;
  }
}

You can also query the suppression list before sending:

const checkRes = await fetch(
  "https://api.senddio.com/v1/suppressions?address=" + encodeURIComponent(email),
  { headers: { "Authorization": "Bearer " + SENDDIO_KEY } }
);
const { suppressed } = await checkRes.json();
if (suppressed) { /* skip or fall back */ }

Tagging for delivery analytics

The tag field on every send request is your primary tool for delivery analytics. Tag by event type:

{
  "channel": "email",
  "tag": "order-shipped",
  ...
}

You can then filter by tag in the Senddio dashboard to see delivery rates for a specific notification type across all channels. The messages API also supports tag filtering, so you can pull delivery stats programmatically:

const res = await fetch(
  "https://api.senddio.com/v1/messages?tag=order-shipped&from=" + sevenDaysAgo,
  { headers: { "Authorization": "Bearer " + SENDDIO_KEY } }
);
const { messages } = await res.json();

const delivered = messages.filter(m => m.status === "delivered").length;
const total = messages.length;
const rate = (delivered / total * 100).toFixed(1) + "%";
console.log("order-shipped delivery rate (7d):", rate);

Channel-specific formatting

The same product event needs different formatting for each channel. A useful pattern is to define the notification payload with channel-specific variants:

const orderShippedEvent = {
  eventType: "order-shipped",
  priority: "standard",
  subject: "Your order ORD-7743 has shipped",
  html: "<h2>Your order is on its way</h2><p>Track it: yourdomain.com/track/ORD-7743</p>",
  text: "Your order ORD-7743 has shipped. Track it: yourdomain.com/track/ORD-7743",
  smsBody: "Order ORD-7743 shipped. Track: yourdomain.com/track/ORD-7743",
  slackBody: ":package: *Order ORD-7743 shipped* — track at yourdomain.com/track/ORD-7743"
};

SMS needs a compact version that fits in a single segment (160 chars for GSM-7). Slack benefits from markdown formatting. Email can be rich HTML. Senddio handles none of this for you — but keeping the variant content alongside the event payload in a single object means you do not scatter channel-specific logic across your codebase.

Monitoring the notification system

Once your notification service is in production, you want to answer these questions quickly:

  • Did this specific user receive this specific notification?
  • What is the delivery rate for this event type over the last 7 days?
  • Is there a bounce spike on a particular domain or number range?

The Senddio logs page answers the first question in under 10 seconds: filter by tag, find the user's email or phone, open the delivery timeline. The AI anomaly detection feature (on Pro plans) surfaces the third question automatically — it compares delivery metrics against historical baselines and creates an insight in your dashboard when it detects a bounce spike or delivery drop.

Summary

  • All six channels use the same POST /v1/send endpoint. Only the channel field and channel-specific fields change.
  • Store notification preferences per user in your database. Your application owns the routing decision.
  • For standard notifications, send sequentially and stop at the first successful queue. For urgent events, use Promise.allSettled to send all channels in parallel.
  • Handle delivery outcomes via webhook. Update your delivery attempt records on message.delivered, message.bounced, and message.failed.
  • Implement channel fallback in your webhook handler when the primary channel bounces or fails.
  • Handle SUPPRESSED_RECIPIENT errors to avoid retrying contacts that are suppressed.
  • Tag every send with the event type. Use tags to pull delivery analytics by notification type across all channels.
Published
September 8, 2026
Share on XAll posts

Try Senddio free.

One API, six channels. 300 emails per day on the free plan — no credit card.

Read the API docsStart free