This guide provides a step-by-step walkthrough for building a robust system to send bulk SMS marketing campaigns using Node.js, Express, and the Vonage Messages API. We will cover everything from initial project setup and core sending logic to database integration, webhook handling, rate limiting, security, and deployment considerations.
By the end of this tutorial, you will have a functional application capable of managing recipient lists, sending templated messages, handling replies and delivery statuses, and respecting API rate limits – serving as a solid foundation for a production-grade SMS marketing platform.
Project Overview and Goals
What We're Building:
A Node.js application using the Express framework that exposes an API to:
- Create and manage SMS marketing campaigns (defining messages and recipient lists).
- Trigger the sending of these campaigns to multiple recipients via the Vonage Messages API.
- Handle incoming SMS replies (e.g., opt-outs) and message delivery status updates via Vonage webhooks.
- Store campaign, recipient, and message status data in a PostgreSQL database using Prisma.
Problem Solved:
This system provides a scalable and manageable way to execute SMS marketing campaigns, automating the sending process while incorporating essential features like rate limiting, status tracking, and reply handling, which are crucial for compliance and effectiveness.
Technologies Used:
- Node.js: JavaScript runtime environment for building the backend server.
- Express: Minimalist web framework for Node.js to build the API and handle webhooks.
- Vonage Messages API: Used for sending SMS messages. Offers multi-channel capabilities, but we'll focus on SMS. We'll use the
@vonage/server-sdk
. - PostgreSQL: Relational database for storing campaign data, recipient lists, and message logs.
- Prisma: Next-generation ORM for Node.js and TypeScript (we'll use it with JavaScript) to interact with the database.
- dotenv: Module to load environment variables from a
.env
file. - ngrok: Tool to expose local development servers to the internet for testing webhooks.
System Architecture:
+-----------------+ +---------------------+ +-----------------+ +-------------+
| API Client |----->| Node.js/Express |----->| Vonage API |----->| SMS Network |
| (e.g., curl, UI)| | (Campaign Sender) |<-----| (Messages API) |<-----| |
+-----------------+ | + Prisma + Postgres | +-----------------+ +-------------+
| ^ | |
| | (DB Operations) | (SMS Sent) | (Recipient)
| | v v
| +------+------+ +-----------------+
| | Webhook Hndlr |<-----------------------| Vonage Webhooks |
| +-------------+ | (Inbound_ Status) |
| +-----------------+
+---------------------+
Prerequisites:
- Node.js (v16 or later recommended) and npm/yarn installed.
- A Vonage API account. Sign up here if you don't have one.
- Vonage CLI installed (
npm install -g @vonage/cli
). While not strictly required for this guide's code_ it's useful for managing your account. ngrok
installed and authenticated. Download here.- Access to a PostgreSQL database (local or cloud-based).
> Important for US Traffic: If sending SMS to US numbers using standard long codes (+1 country code), you must register for A2P 10DLC (Application-to-Person 10-Digit Long Code) via Vonage after the initial setup. This is a mandatory carrier requirement for compliance and deliverability.
1. Setting up the Project
Let's initialize our Node.js project and install the necessary dependencies.
-
Create Project Directory:
mkdir vonage-sms-campaign cd vonage-sms-campaign
-
Initialize Node.js Project:
npm init -y
This creates a
package.json
file. -
Install Dependencies:
# Core functionalities npm install express @vonage/server-sdk dotenv @prisma/client # Development dependencies npm install --save-dev prisma nodemon
express
: Web framework.@vonage/server-sdk
: Official Vonage Node.js SDK.dotenv
: Loads environment variables from.env
.@prisma/client
: Prisma's database client.prisma
: Prisma CLI for migrations and schema management.nodemon
: Utility to automatically restart the server during development.
-
Project Structure: Create the following directories and files:
vonage-sms-campaign/ ├── prisma/ │ └── schema.prisma (Will be generated) ├── src/ │ ├── routes/ │ │ ├── campaigns.js │ │ └── webhooks.js │ ├── services/ │ │ ├── vonageService.js │ │ └── campaignService.js │ ├── middleware/ │ │ └── errorHandler.js │ └── server.js ├── .env ├── .gitignore └── package.json
-
Configure
.gitignore
: Create a.gitignore
file to avoid committing sensitive files and unnecessary directories:# Dependencies node_modules/ # Prisma prisma/migrations/*/*.sql prisma/dev.db* # Environment Variables .env # Logs *.log # OS generated files .DS_Store Thumbs.db # Private Key (CRITICAL: Do NOT commit private keys!) # Storing directly is insecure; use secure methods for production. private.key *.pem
> Note: Storing private keys directly in the project is convenient for development but highly insecure and strongly discouraged for production. Use secure secret management solutions.
-
Define Environment Variables (
.env
): Create a.env
file in the project root. You will obtain these values in the next section.# Vonage Credentials (Using Application ID and Private Key for Messages API) VONAGE_APPLICATION_ID=YOUR_VONAGE_APPLICATION_ID # VONAGE_PRIVATE_KEY_PATH=./private.key # DEV ONLY - In production, use VONAGE_PRIVATE_KEY_CONTENT env var instead. VONAGE_PRIVATE_KEY_PATH=./private.key VONAGE_NUMBER=YOUR_VONAGE_VIRTUAL_NUMBER # The number you'll send FROM # Database Connection DATABASE_URL="postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public" # Server Configuration PORT=3000 # Rate Limiting (Messages per second) - Adjust based on your number type/Vonage agreement # 1 message/sec is typical for US Long Codes before A2P 10DLC registration VONAGE_SEND_RATE_LIMIT_MPS=1
VONAGE_APPLICATION_ID
: Found in your Vonage Application settings.VONAGE_PRIVATE_KEY_PATH
: Path to theprivate.key
file you'll download (for development only).VONAGE_NUMBER
: The Vonage virtual number linked to your application.DATABASE_URL
: Standard database connection string. Replace placeholders.PORT
: Port the Express server will listen on.VONAGE_SEND_RATE_LIMIT_MPS
: Crucial for throttling. Start with1
if using a standard US long code without A2P 10DLC registration.
-
Configure
nodemon
: Add start scripts to yourpackage.json
for development and production:// package.json { "scripts": { "start": "node src/server.js", "dev": "nodemon src/server.js" // Add prisma commands later } }
2. Vonage Account and Application Setup
Configure your Vonage account to use the Messages API and obtain the necessary credentials.
-
Log in to Vonage Dashboard: Access your Vonage API Dashboard.
-
Set Default SMS API:
- Navigate to API Settings in the left sidebar.
- Scroll down to SMS settings.
- Ensure Messages API is selected as the default API for sending SMS messages. This is crucial as it determines the webhook format and SDK usage patterns.
- Click Save changes.
-
Create a Vonage Application:
- Navigate to Applications > Create a new application.
- Give it a name (e.g., ""SMS Marketing Campaign App"").
- Click Generate public and private key. Immediately save the
private.key
file that downloads. Place it in your project root directory (or the path specified inVONAGE_PRIVATE_KEY_PATH
in your.env
). > Security Warning: Only store the key file locally for development. In production, load the key content securely, e.g., via environment variables. Treat this key like a password – keep it secure. - Note the Application ID displayed on the page. This is your
VONAGE_APPLICATION_ID
. - Enable the Messages capability.
- Enter placeholder URLs for now (we'll update them later with
ngrok
):- Inbound URL:
http://localhost:3000/webhooks/inbound
(Method:POST
) - Status URL:
http://localhost:3000/webhooks/status
(Method:POST
) > Note for US Sending: After this setup, you will need to proceed with A2P 10DLC registration through the Vonage dashboard or API if targeting US numbers.
- Inbound URL:
-
Link a Virtual Number:
- Scroll down to the Link virtual numbers section within the application settings.
- If you don't have a number, go to Numbers > Buy numbers to purchase one (ensure it has SMS capability).
- Link your desired sending number to this application. This number will be your
VONAGE_NUMBER
.
-
Update
.env
File: Fill in theVONAGE_APPLICATION_ID
,VONAGE_PRIVATE_KEY_PATH
(e.g.,./private.key
), andVONAGE_NUMBER
in your.env
file with the values obtained above. Remember the security implications ofVONAGE_PRIVATE_KEY_PATH
.
3. Database Schema and Data Layer (Prisma/PostgreSQL)
We'll use Prisma to define our database schema and interact with PostgreSQL.
-
Initialize Prisma:
npx prisma init --datasource-provider postgresql
This creates the
prisma/
directory and aprisma/schema.prisma
file, and updates.env
with a templateDATABASE_URL
. Ensure your actual connection string is in.env
. -
Define Schema (
prisma/schema.prisma
): Replace the contents ofprisma/schema.prisma
with the following models:// This is your Prisma schema file, // learn more about it in the docs: https://pris.ly/d/prisma-schema generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model Campaign { id Int @id @default(autoincrement()) name String messageBody String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt recipients Recipient[] messages MessageLog[] @@index([name]) } model Recipient { id Int @id @default(autoincrement()) // Ensures phone numbers are globally unique across all campaigns. // This means a number cannot exist in multiple active recipient lists simultaneously. // It also implies 'STOP' requests will affect all instances of this number. phoneNumber String @unique campaignId Int campaign Campaign @relation(fields: [campaignId], references: [id]) createdAt DateTime @default(now()) subscribed Boolean @default(true) // For opt-out tracking messages MessageLog[] @@index([phoneNumber]) @@index([campaignId]) } enum MessageStatus { PENDING SENT DELIVERED FAILED REJECTED // e.g., carrier violation UNKNOWN } model MessageLog { id Int @id @default(autoincrement()) campaignId Int recipientId Int vonageMessageId String? @unique // The ID returned by Vonage status MessageStatus @default(PENDING) sentAt DateTime? deliveredAt DateTime? errorCode String? // Store error code from Vonage if failed errorMessage String? // Store error message createdAt DateTime @default(now()) updatedAt DateTime @updatedAt campaign Campaign @relation(fields: [campaignId], references: [id]) recipient Recipient @relation(fields: [recipientId], references: [id]) @@index([vonageMessageId]) @@index([status]) @@index([campaignId]) @@index([recipientId]) }
- Campaign: Stores the name and message body.
- Recipient: Stores the phone number and links to a campaign. Includes a
subscribed
flag for opt-outs. ThephoneNumber
is globally unique. - MessageLog: Tracks the status of each individual message sent to a recipient as part of a campaign. Links to both Campaign and Recipient, stores the Vonage Message UUID, status, timestamps, and potential error details.
-
Apply Schema to Database (Migration):
# Ensure your DATABASE_URL in .env is correct before running npx prisma migrate dev --name init
Prisma will create the SQL migration file in
prisma/migrations/
and apply it to your database, creating the tables. -
Generate Prisma Client: Prisma Client is generated automatically after
migrate dev
, but you can run it manually if needed:npx prisma generate
This generates the typed database client in
node_modules/@prisma/client
. -
Add Prisma Commands to
package.json
:// package.json { "scripts": { "start": "node src/server.js", "dev": "nodemon src/server.js", "prisma:migrate": "prisma migrate dev", "prisma:studio": "prisma studio", "prisma:generate": "prisma generate" } }
npm run prisma:migrate -- --name <migration_name>
: Create new migrations.npm run prisma:studio
: Open a GUI to view/edit database data.
4. Implementing Core Sending Logic
Now, let's implement the services to interact with Vonage and manage the campaign sending process, including rate limiting.
-
Vonage Service (
src/services/vonageService.js
): Initialize the Vonage SDK and provide a function to send a single SMS.// src/services/vonageService.js const { Vonage } = require('@vonage/server-sdk'); const { Message } = require('@vonage/messages'); // Import Message class const path = require('path'); const fs = require('fs'); // Needed to read key file content require('dotenv').config(); // Determine private key source: prefer content env var, fallback to path (dev only) let privateKey; if (process.env.VONAGE_PRIVATE_KEY_CONTENT) { privateKey = process.env.VONAGE_PRIVATE_KEY_CONTENT.replace(/\\n/g, '\n'); // Handle escaped newlines console.log('Using Vonage private key from environment variable.'); } else if (process.env.VONAGE_PRIVATE_KEY_PATH) { try { const privateKeyPath = path.resolve(process.env.VONAGE_PRIVATE_KEY_PATH); privateKey = fs.readFileSync(privateKeyPath); console.warn('Using Vonage private key from file path (Development only - insecure for production).'); } catch (err) { console.error(`Error reading private key file at ${process.env.VONAGE_PRIVATE_KEY_PATH}:`, err); process.exit(1); } } else { console.error('Missing Vonage private key. Set VONAGE_PRIVATE_KEY_CONTENT (recommended) or VONAGE_PRIVATE_KEY_PATH (dev only).'); process.exit(1); } // Input validation for required environment variables if (!process.env.VONAGE_APPLICATION_ID || !privateKey || !process.env.VONAGE_NUMBER) { console.error('Missing required Vonage environment variables (Application ID, Private Key, Number)!'); process.exit(1); // Exit if configuration is missing } const vonage = new Vonage({ applicationId: process.env.VONAGE_APPLICATION_ID, privateKey: privateKey, // SDK expects the key content or path }); /** * Sends an SMS message using the Vonage Messages API. * @param {string} toNumber - The recipient's phone number (E.164 format recommended). * @param {string} messageText - The text content of the SMS. * @returns {Promise<object>} - Promise resolving with the Vonage API response. * @throws {Error} - Throws a structured error if sending fails. */ async function sendSms(toNumber, messageText) { console.log(`Attempting to send SMS to ${toNumber}`); try { const resp = await vonage.messages.send( new Message( // Use the Message class constructor { text: messageText }, // Content { type: 'sms', number: toNumber }, // To { type: 'sms', number: process.env.VONAGE_NUMBER } // From ) ); console.log(`Message sent successfully to ${toNumber}. Message UUID: ${resp.messageUuid}`); return resp; // Contains message_uuid } catch (err) { const status = err?.response?.status; const title = err?.response?.data?.title; const detail = err?.response?.data?.detail || err.message; console.error(`Error sending SMS to ${toNumber}: Status ${status}, Title: ${title}, Detail: ${detail}`); // Rethrow a more structured error const sendError = new Error(`Failed to send SMS to ${toNumber}. Vonage status: ${status || 'N/A'}, title: ${title || 'N/A'}, detail: ${detail}`); sendError.vonageStatus = status; sendError.vonageTitle = title; sendError.vonageDetail = detail; throw sendError; } } module.exports = { sendSms, vonage // Export instance if needed elsewhere (e.g., for signature verification) };
- Loads credentials from
.env
, prioritizingVONAGE_PRIVATE_KEY_CONTENT
overVONAGE_PRIVATE_KEY_PATH
for better production security. - Initializes the
Vonage
SDK instance. - The
sendSms
function usesvonage.messages.send
with the correctMessage
class structure. - Includes basic logging and throws a more structured error on failure, potentially including status and title from the Vonage response.
- Loads credentials from
-
Campaign Service (
src/services/campaignService.js
): Handles fetching campaign data, iterating recipients, applying rate limits, sending messages viavonageService
, and logging results to the database.// src/services/campaignService.js const { PrismaClient, MessageStatus } = require('@prisma/client'); const { sendSms } = require('./vonageService'); require('dotenv').config(); const prisma = new PrismaClient(); // Rate limiting configuration const messagesPerSecond = parseInt(process.env.VONAGE_SEND_RATE_LIMIT_MPS || '1', 10); const delayBetweenMessages = messagesPerSecond > 0 ? 1000 / messagesPerSecond : 1000; // Delay in milliseconds // Simple utility for adding delay const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms)); // Note: This simple 'setTimeout' approach works for a single process. // For multi-process scaling, a distributed rate limiter (e.g., using Redis) is needed. /** * Starts sending an SMS campaign. Fetches recipients, sends messages with rate limiting, * and logs the status of each message attempt. * @param {number} campaignId - The ID of the campaign to send. */ async function startCampaign(campaignId) { console.log(`Starting campaign ID: ${campaignId}`); try { const campaign = await prisma.campaign.findUnique({ where: { id: campaignId }, include: { recipients: { where: { subscribed: true } // Only send to subscribed recipients } }, }); if (!campaign) { console.error(`Campaign not found: ${campaignId}`); return; } if (!campaign.recipients || campaign.recipients.length === 0) { console.log(`Campaign ${campaignId} has no subscribed recipients.`); return; } console.log(`Found ${campaign.recipients.length} recipients for campaign ${campaignId}. Starting send process...`); for (const recipient of campaign.recipients) { let messageStatus = MessageStatus.PENDING; let vonageMessageId = null; let errorCode = null; let errorMessage = null; let sentAt = null; try { // Send SMS via Vonage Service const response = await sendSms(recipient.phoneNumber, campaign.messageBody); vonageMessageId = response.messageUuid; // Correct key is messageUuid messageStatus = MessageStatus.SENT; // Assume SENT, status webhook will update to DELIVERED/FAILED sentAt = new Date(); console.log(`Successfully initiated send to ${recipient.phoneNumber} for campaign ${campaignId}. UUID: ${vonageMessageId}`); } catch (error) { console.error(`Failed to send to ${recipient.phoneNumber} for campaign ${campaignId}:`, error.message); messageStatus = MessageStatus.FAILED; // Attempt to extract Vonage error details passed from vonageService. // Structure depends on the error thrown; parse message or specific properties if available. errorMessage = error.message || 'Unknown send error'; errorCode = error.vonageStatus || error.code || 'SEND_FAILURE'; // Use status code if available sentAt = new Date(); // Record failure time } // Log the attempt to the database try { await prisma.messageLog.create({ data: { campaignId: campaign.id, recipientId: recipient.id, vonageMessageId: vonageMessageId, status: messageStatus, sentAt: sentAt, errorCode: errorCode ? String(errorCode) : null, // Ensure errorCode is string or null errorMessage: errorMessage, }, }); console.log(`Logged message status ${messageStatus} for recipient ${recipient.id} in campaign ${campaignId}`); } catch (dbError) { console.error(`Database Error: Failed to log message status for recipient ${recipient.id}:`, dbError); // Critical: Failed to log message status. Implement robust logging (e.g., to a file or external service) // and consider alerting mechanisms here. Retrying might be complex due to the ongoing campaign loop. } // Apply Rate Limiting Delay BEFORE the next iteration console.log(`Waiting ${delayBetweenMessages}ms before next message...`); await wait(delayBetweenMessages); } console.log(`Finished processing campaign ID: ${campaignId}`); } catch (error) { console.error(`Error processing campaign ${campaignId}:`, error); // Handle errors fetching campaign or recipients } } module.exports = { startCampaign, };
- Fetches the campaign and its subscribed recipients.
- Iterates through recipients.
- Calls
vonageService.sendSms
for each. - Rate Limiting: Uses
setTimeout
(wait
function) to pause betweensendSms
calls based onVONAGE_SEND_RATE_LIMIT_MPS
. Includes a note about its single-process limitation. - Logs the initial status (
SENT
orFAILED
) andvonageMessageId
(if successful) to theMessageLog
table using Prisma. - Includes improved error handling for sending (using structured error from
vonageService
) and database logging (with more specific advice).
> Important Note on Rate Limiting & A2P 10DLC: >
- Crucial for US Traffic: You must register for Application-to-Person (A2P) messaging via the 10DLC (10-Digit Long Code) system for sending to US numbers. This is mandatory. > * The default 1 message/second limit for US long codes is very slow for bulk campaigns and may lead to blocking without registration. > * Your approved 10DLC registration will grant higher throughput limits determined by carriers. You'll need to adjust
VONAGE_SEND_RATE_LIMIT_MPS
accordingly. > * Toll-Free numbers and Short Codes offer significantly higher throughput but have different registration processes and costs. > * Contact Vonage support if you need API concurrency limits raised beyond the default (around 30 requests/sec).
5. Building the API Layer (Express)
Create Express routes to manage campaigns and trigger sending.
-
Basic Server Setup (
src/server.js
): Set up the main Express application.// src/server.js require('dotenv').config(); const express = require('express'); const campaignRoutes = require('./routes/campaigns'); const webhookRoutes = require('./routes/webhooks'); const errorHandler = require('./middleware/errorHandler'); const app = express(); const PORT = process.env.PORT || 3000; // Middleware // Use express.json() for API requests bodies app.use(express.json()); // Use express.urlencoded() for webhook form data (commonly used by Vonage webhooks) app.use(express.urlencoded({ extended: true })); // Routes app.get('/health', (req, res) => res.status(200).send('OK')); // Health check app.use('/api/campaigns', campaignRoutes); app.use('/webhooks', webhookRoutes); // Expose webhooks at /webhooks path // Error Handling Middleware (Should be last) app.use(errorHandler); app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); console.log(`Webhook Base URL for ngrok (if running): http://localhost:${PORT}/webhooks`); });
- Loads environment variables.
- Sets up essential middleware (
express.json
,express.urlencoded
). - Includes a basic
/health
check endpoint. - Mounts the campaign and webhook routes.
- Includes error handling middleware.
-
Campaign Routes (
src/routes/campaigns.js
): Define endpoints for creating and sending campaigns.// src/routes/campaigns.js const express = require('express'); const { PrismaClient } = require('@prisma/client'); const { startCampaign } = require('../services/campaignService'); // Import the service const router = express.Router(); const prisma = new PrismaClient(); // POST /api/campaigns - Create a new campaign with recipients router.post('/', async (req, res, next) => { const { name, messageBody, recipients } = req.body; // recipients = array of phone numbers ["+1...", "+44..."] // Basic Input Validation if (!name || !messageBody || !Array.isArray(recipients) || recipients.length === 0) { return res.status(400).json({ error: 'Missing required fields: name, messageBody, and a non-empty recipients array.' }); } // Consider adding more robust validation: check phone number format (e.g., using libphonenumber-js), check name/message length, etc. try { const newCampaign = await prisma.campaign.create({ data: { name: name, messageBody: messageBody, recipients: { // Attempt to create recipients. Prisma's @unique constraint on phoneNumber // will cause an error if a number already exists globally. create: recipients.map(phone => ({ phoneNumber: phone, // subscribed defaults to true per schema })), }, }, include: { recipients: true, // Include recipients in the response }, }); res.status(201).json(newCampaign); } catch (error) { console.error('Error creating campaign:', error); // Handle unique constraint violation (P2002) specifically if (error.code === 'P2002' && error.meta?.target?.includes('phoneNumber')) { // This occurs because phoneNumber is globally unique per the schema return res.status(409).json({ error: 'Conflict: One or more phone numbers already exist globally. Cannot add duplicates.', details: `Unique constraint violation on field: ${error.meta.target}` }); } next(error); // Pass other errors to generic handler } }); // POST /api/campaigns/:id/send - Trigger sending a specific campaign router.post('/:id/send', async (req, res, next) => { const campaignId = parseInt(req.params.id, 10); if (isNaN(campaignId)) { return res.status(400).json({ error: 'Invalid campaign ID.' }); } try { // Find the campaign to ensure it exists before trying to send const campaign = await prisma.campaign.findUnique({ where: { id: campaignId } }); if (!campaign) { return res.status(404).json({ error: 'Campaign not found.' }); } // *** Crucial: Trigger sending asynchronously *** // Don't await startCampaign here, otherwise the HTTP request will hang // until all messages are sent (which could take hours for large campaigns). startCampaign(campaignId).catch(err => { // Log errors happening in the background task console.error(`Background campaign sending failed for ID ${campaignId}:`, err); }); // Immediately respond to the client res.status(202).json({ message: `Campaign ${campaignId} sending process initiated.` }); } catch (error) { console.error(`Error initiating campaign send for ID ${campaignId}:`, error); next(error); } }); // GET /api/campaigns/:id/status - (Optional) Get campaign status summary router.get('/:id/status', async (req, res, next) => { const campaignId = parseInt(req.params.id, 10); if (isNaN(campaignId)) { return res.status(400).json({ error: 'Invalid campaign ID.' }); } try { const statusCounts = await prisma.messageLog.groupBy({ by: ['status'], where: { campaignId: campaignId }, _count: { status: true, }, }); const totalRecipients = await prisma.recipient.count({ where: { campaignId: campaignId } }); const summary = statusCounts.reduce((acc, curr) => { acc[curr.status] = curr._count.status; return acc; }, {}); res.json({ campaignId, totalRecipients, statusSummary: summary }); } catch (error) { next(error); } }); module.exports = router;
POST /
: Creates campaign/recipients. Includes specific error handling for the globalphoneNumber
unique constraint (P2002
). Adds suggestion for validation libraries.POST /:id/send
: TriggersstartCampaign
asynchronously (.catch()
for background errors) and returns202 Accepted
immediately.GET /:id/status
: (Optional) Provides a summary of message statuses for a given campaign using Prisma'sgroupBy
.