Vonage (formerly Nexmo) is a communications API platform — new Vonage({ apiKey, apiSecret }) creates the SDK. vonage.sms.send({ to, from, text }) sends an SMS. vonage.verify.start({ number, brand, codeLength }) begins OTP verification; vonage.verify.check({ requestId, code }) validates the code. vonage.voice.createOutboundCall({ to, from, ncco }) makes a voice call with NCCO (Nexmo Call Control Objects). NCCO actions include talk for text-to-speech, record for call recording, and connect for call forwarding. vonage.messages.send({ channel, to, from, text }) uses the Messages API for WhatsApp and MMS. Webhook signature verified with Vonage.verifySignature(params, secret). Inbound SMS hits the webhook URL configured in Vonage dashboard — req.body.text and req.body.msisdn contain the message and sender. Claude Code generates Vonage SMS delivery, OTP flows, voice call NCCO, and webhook handling.
CLAUDE.md for Vonage
## Vonage Stack
- Version: @vonage/server-sdk >= 3.12
- Init: const vonage = new Vonage({ apiKey: VONAGE_API_KEY, apiSecret: VONAGE_API_SECRET })
- SMS: await vonage.sms.send({ to: "15551234567", from: "Acme", text: "Your code is 123456" })
- Verify start: const { requestId } = await vonage.verify.start({ number, brand: "Acme", codeLength: 6 })
- Verify check: const { status } = await vonage.verify.check({ requestId, code })
- Voice: await vonage.voice.createOutboundCall({ to: [{ type: "phone", number }], from: { type: "phone", number: FROM_NUMBER }, ncco: [{ action: "talk", text }] })
- Webhook verify: Vonage.verifySignature(req.query, VONAGE_SIGNATURE_SECRET)
Vonage Server Utilities
// lib/communications/vonage.ts — Vonage SDK utilities
import { Vonage } from "@vonage/server-sdk"
import { Auth } from "@vonage/auth"
import type { SMSParams } from "@vonage/sms"
const auth = new Auth({
apiKey: process.env.VONAGE_API_KEY!,
apiSecret: process.env.VONAGE_API_SECRET!,
})
export const vonage = new Vonage(auth)
const FROM_NUMBER = process.env.VONAGE_FROM_NUMBER! // Your Vonage virtual number
const BRAND_NAME = process.env.APP_NAME ?? "MyApp"
// ── SMS ────────────────────────────────────────────────────────────────────
export type SMSResult = {
success: boolean
messageId?: string
errorText?: string
}
export async function sendSMS(to: string, text: string): Promise<SMSResult> {
try {
const response = await vonage.sms.send({
to: to.replace(/\D/g, ""), // Strip non-digits
from: FROM_NUMBER,
text,
})
const message = response.messages[0]
if (message.status !== "0") {
return { success: false, errorText: message["error-text"] }
}
return { success: true, messageId: message["message-id"] }
} catch (err) {
console.error("[Vonage SMS]", err)
return { success: false, errorText: String(err) }
}
}
// Common SMS patterns
export async function sendOrderConfirmationSMS(phone: string, params: {
orderId: string
total: string
estimatedDelivery: string
}): Promise<void> {
await sendSMS(phone,
`Order #${params.orderId} confirmed! Total: ${params.total}. Est. delivery: ${params.estimatedDelivery}. Reply STOP to opt out.`,
)
}
export async function sendShippingUpdateSMS(phone: string, params: {
orderId: string
trackingUrl: string
}): Promise<void> {
await sendSMS(phone,
`Your order #${params.orderId} has shipped! Track: ${params.trackingUrl}`,
)
}
// ── OTP / Verify ──────────────────────────────────────────────────────────
type VerifyStatus = "success" | "invalid_code" | "expired" | "already_verified" | "error"
export async function startPhoneVerification(phoneNumber: string): Promise<{
requestId: string | null
error?: string
}> {
try {
const response = await vonage.verify.start({
number: phoneNumber.replace(/\D/g, ""),
brand: BRAND_NAME,
codeLength: 6,
lg: "en-us",
nextEventWait: 120, // 2 min between channels
})
if (response.status !== "0") {
return { requestId: null, error: response.errorText }
}
return { requestId: response.requestId }
} catch (err) {
return { requestId: null, error: String(err) }
}
}
export async function checkVerificationCode(
requestId: string,
code: string,
): Promise<{ status: VerifyStatus }> {
try {
const response = await vonage.verify.check({ requestId, code })
const statusMap: Record<string, VerifyStatus> = {
"0": "success",
"16": "invalid_code",
"6": "expired",
"17": "already_verified",
}
return { status: statusMap[response.status] ?? "error" }
} catch (err) {
console.error("[Vonage verify check]", err)
return { status: "error" }
}
}
export async function cancelVerification(requestId: string): Promise<void> {
await vonage.verify.cancel(requestId)
}
// ── Voice ─────────────────────────────────────────────────────────────────
export type NCCOAction =
| { action: "talk"; text: string; bargeIn?: boolean; voiceName?: string }
| { action: "record"; eventUrl: string[]; endOnSilence?: number }
| { action: "connect"; endpoint: [{ type: "phone"; number: string }]; timeout?: number }
| { action: "input"; type: ["dtmf"]; dtmf: { digits?: string; submitOnHash?: boolean; timeOut?: number } }
export async function makeOutboundCall(to: string, ncco: NCCOAction[]): Promise<{
callUuid: string | null
error?: string
}> {
try {
const response = await vonage.voice.createOutboundCall({
to: [{ type: "phone", number: to.replace(/\D/g, "") }],
from: { type: "phone", number: FROM_NUMBER },
ncco,
})
return { callUuid: response.uuid ?? null }
} catch (err) {
console.error("[Vonage voice]", err)
return { callUuid: null, error: String(err) }
}
}
// Text-to-speech call
export async function sayMessage(to: string, message: string): Promise<void> {
await makeOutboundCall(to, [
{ action: "talk", text: message, voiceName: "Amy" },
])
}
Next.js API Routes
// app/api/verify/phone/route.ts — start OTP
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@clerk/nextjs/server"
import { startPhoneVerification } from "@/lib/communications/vonage"
import { rateLimiter } from "@/lib/rate-limit"
import { z } from "zod"
const PhoneSchema = z.object({
phone: z.string().regex(/^\+?[1-9]\d{7,14}$/, "Invalid phone number"),
})
export async function POST(request: NextRequest) {
const { userId } = await auth()
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
// Rate limit: 3 verify starts per hour per user
const limited = await rateLimiter.check(`verify:${userId}`, 3, "1h")
if (limited) {
return NextResponse.json({ error: "Too many verification attempts" }, { status: 429 })
}
const body = PhoneSchema.safeParse(await request.json())
if (!body.success) {
return NextResponse.json({ error: body.error.issues[0].message }, { status: 400 })
}
const { requestId, error } = await startPhoneVerification(body.data.phone)
if (!requestId) {
return NextResponse.json({ error: error ?? "Verification failed" }, { status: 400 })
}
// Store requestId in session (not exposed to client)
// In production, store in Redis with userId → requestId mapping
return NextResponse.json({ success: true })
}
// app/api/verify/phone/check/route.ts — validate OTP code
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@clerk/nextjs/server"
import { checkVerificationCode } from "@/lib/communications/vonage"
import { db } from "@/lib/db"
import { users } from "@/lib/db/schema"
import { eq } from "drizzle-orm"
export async function POST(request: NextRequest) {
const { userId } = await auth()
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { requestId, code } = await request.json()
const { status } = await checkVerificationCode(requestId, code)
if (status !== "success") {
const messages = {
invalid_code: "Incorrect code. Please try again.",
expired: "Code has expired. Please request a new one.",
already_verified: "This number has already been verified.",
error: "Verification failed. Please try again.",
}
return NextResponse.json(
{ error: messages[status as keyof typeof messages] ?? "Verification failed" },
{ status: 422 },
)
}
// Mark phone as verified in DB (phone stored from verify start in real app)
await db.update(users)
.set({ phoneVerified: true })
.where(eq(users.id, userId))
return NextResponse.json({ success: true })
}
Inbound SMS Webhook
// app/api/webhooks/vonage/sms/route.ts — handle inbound SMS
import { NextRequest, NextResponse } from "next/server"
import { Vonage } from "@vonage/server-sdk"
export async function POST(request: NextRequest) {
const body = await request.json()
// Verify webhook signature
const isValid = Vonage.verifySignature(
{ ...body, ...Object.fromEntries(request.nextUrl.searchParams) },
process.env.VONAGE_SIGNATURE_SECRET!,
)
if (!isValid) {
return new Response("Invalid signature", { status: 401 })
}
const { msisdn: from, to, text, messageId } = body
console.log(`[Vonage inbound SMS] from=${from} to=${to} text="${text}" id=${messageId}`)
// Handle opt-out
if (text?.trim().toUpperCase() === "STOP") {
await handleSMSOptOut(from)
}
// Handle HELP
if (text?.trim().toUpperCase() === "HELP") {
const { sendSMS } = await import("@/lib/communications/vonage")
await sendSMS(from, `Reply STOP to unsubscribe. For help visit ${process.env.APP_URL}/help`)
}
return new Response("OK", { status: 200 })
}
// GET handler for Vonage delivery receipts
export async function GET(request: NextRequest) {
const params = Object.fromEntries(request.nextUrl.searchParams)
console.log("[Vonage DLR]", {
messageId: params["message-id"],
status: params.status,
msisdn: params.msisdn,
})
return new Response("OK", { status: 200 })
}
async function handleSMSOptOut(phoneNumber: string): Promise<void> {
const { db } = await import("@/lib/db")
const { users } = await import("@/lib/db/schema")
const { eq } = await import("drizzle-orm")
await db.update(users)
.set({ smsOptOut: true })
.where(eq(users.phone, phoneNumber))
}
For the Twilio alternative when a more mature, enterprise-grade communications platform with a larger global SMS carrier network, better delivery rates, advanced phone number provisioning, and a richer Conversations API for WhatsApp is needed — Twilio has broader global coverage and more carrier partnerships while Vonage has competitive pricing and unified API surface, see the Twilio guide. For the Plivo alternative when low-cost SMS delivery with competitive international rates and an AWS-style console is preferred — Plivo focuses on cost-effective bulk SMS while Vonage has richer voice and OTP verification features, see the Plivo guide. The Claude Skills 360 bundle includes Vonage skill sets covering SMS, OTP verification, and voice calls. Start with the free tier to try communications API generation.