This tutorial walks through sending your first SMS from a Node.js application — from getting API credentials to a production-shaped Express endpoint with proper error handling. By the end, you'll have a working /api/send-sms route you can drop straight into a real project.
You'll need:
npm install @sendexa/sdk
or, if you're using Bun:
bun add @sendexa/sdk
Never hardcode API keys. Create a .env file in your project root:
SENDEXA_API_KEY=your_api_key_here
SENDEXA_API_SECRET=your_api_secret_here
Make sure .env is in your .gitignore — committing credentials to version control is one of the most common ways API keys end up leaked and abused.
Create a small script to confirm everything's wired up correctly:
// send-test.js
import { Sendexa } from "@sendexa/sdk";
import "dotenv/config";
const sendexa = new Sendexa({
apiKey: process.env.SENDEXA_API_KEY,
apiSecret: process.env.SENDEXA_API_SECRET,
});
async function main() {
const result = await sendexa.sms.send({
to: "+233501234567",
from: "SENDEXA",
message: "Hello from my Node.js app! 🚀",
});
console.log(result);
}
main().catch(console.error);
Run it:
node send-test.js
If your credentials and phone number are valid, you should see a response similar to this, and a text message should land on the destination phone within a few seconds:
{
"success": true,
"code": "SMS_SENT",
"message": "Message accepted for delivery.",
"data": {
"messageId": "MSG-8f21c4a9",
"delivery": {
"status": "PENDING",
"route": { "type": "domestic", "destination": "Ghana" }
},
"billing": { "units": 1, "totalCost": 0.01, "currency": "USD" }
}
}
status: "PENDING" means the message has been accepted and handed off to the carrier — final delivery confirmation arrives asynchronously (see webhooks, below).
Now let's wrap this in a real API route your frontend can call:
// routes/sms.js
import express from "express";
import { Sendexa } from "@sendexa/sdk";
const router = express.Router();
const sendexa = new Sendexa({
apiKey: process.env.SENDEXA_API_KEY,
apiSecret: process.env.SENDEXA_API_SECRET,
});
router.post("/send-sms", async (req, res) => {
const { to, message } = req.body;
if (!to || !message) {
return res.status(400).json({ error: "'to' and 'message' are required" });
}
try {
const result = await sendexa.sms.send({
to,
from: "SENDEXA",
message,
});
return res.status(200).json({
success: true,
messageId: result.data.messageId,
});
} catch (error) {
console.error("SMS send failed:", error);
return res.status(502).json({
success: false,
error: "Failed to send SMS. Please try again.",
});
}
});
export default router;
Mount it in your app:
// app.js
import express from "express";
import smsRouter from "./routes/sms.js";
const app = express();
app.use(express.json());
app.use("/api", smsRouter);
app.listen(3000, () => console.log("Server running on port 3000"));
Sending to a malformed number wastes a request and gives users a confusing failure. Validate the format before calling the API — E.164 (+ followed by country code and subscriber number, no spaces or dashes) is the standard to enforce:
function isValidE164(phoneNumber) {
return /^\+[1-9]\d{7,14}$/.test(phoneNumber);
}
// In your route handler, before calling sendexa.sms.send():
if (!isValidE164(to)) {
return res.status(400).json({ error: "Phone number must be in E.164 format, e.g. +233501234567" });
}
Because SMS delivery is asynchronous, the initial API response only tells you the message was accepted, not that it arrived. To get the final delivery outcome, listen for delivery receipts on a webhook endpoint:
// routes/webhooks.js
import express from "express";
const router = express.Router();
router.post("/webhooks/sms-status", express.json(), (req, res) => {
const { messageId, status } = req.body;
console.log(`Message ${messageId} status: ${status}`);
// status is typically one of: DELIVERED, FAILED, EXPIRED
// TODO: update your own database record for this messageId
res.status(200).send("OK");
});
export default router;
Register this URL in your Sendexa dashboard so delivery events get pushed to it in real time, instead of polling.
Common failure modes to handle explicitly rather than letting them bubble up as generic 500s:
try {
await sendexa.sms.send({ to, from: "SENDEXA", message });
} catch (error) {
if (error.status === 401) {
// Invalid or expired API credentials
} else if (error.status === 400) {
// Malformed request — bad phone number, empty message, etc.
} else if (error.status === 429) {
// Rate limited — back off and retry
} else {
// Network error, upstream outage, etc.
}
}
For anything user-facing, don't leak raw API error details to the client — log them server-side and return a clean, generic message.
You now have a working SMS endpoint: credentials loaded from environment variables, input validation, a clean Express route, and a webhook to track delivery status past the initial "accepted" response. From here, the same pattern extends to OTP verification, order notifications, and any other transactional message your app needs to send — swap the message string and you're done.
