Vonage
Send and receive SMS through the Vonage Messages API with the SMS SDK vonage() adapter: JWT vs Basic auth, senders, provider options, signed webhooks, and error mapping.
The vonage() adapter sends SMS through the Vonage Messages API and verifies its signed status and inbound webhooks. To compare it with other providers, see Choose a provider.
Support status
Partial. The adapter passes the shared contract tests. Gaps in Vonage’s documentation limit it:
- It uses only the Messages API (
POST /v1/messages, channelsms). The older SMS API atrest.nexmo.com/sms/jsonis not supported, and its webhooks do not verify with this parser. - Vonage does not document which HTTP status each error code uses. SMS SDK classifies rejections from the documented 401, 402, and 422 statuses and from the error code in the response. Unknown codes become
request, which never falls back. - Vonage describes the webhook
payload_hashonly as “a SHA-256 hash of the payload”. SMS SDK checks it against the exact raw body. If Vonage ever hashes a re-serialized body instead, verification fails withbody_hash_mismatch. - Vonage documents no freshness window for the webhook JWT. SMS SDK accepts tokens issued within 300 seconds.
- No short codes and no MMS.
The same notes are in adapter.support.notes at runtime.
Setup
npm install @opencoredev/sms-sdkpnpm add @opencoredev/sms-sdkyarn add @opencoredev/sms-sdkbun add @opencoredev/sms-sdknub add @opencoredev/sms-sdkaube add @opencoredev/sms-sdk| Variable | Required | Used for |
|---|---|---|
VONAGE_APPLICATION_ID, VONAGE_PRIVATE_KEY |
For JWT auth | Application ID and its RSA private key as PKCS#8 PEM. Literal \n sequences are accepted. |
VONAGE_API_KEY, VONAGE_API_SECRET |
For Basic auth | Account API key and secret. |
VONAGE_FROM |
No | Default sender: a Vonage number or an alphanumeric sender ID. |
VONAGE_SIGNATURE_SECRET |
For webhooks | Signature secret from the Vonage dashboard settings. |
The adapter reads no environment variables itself; the examples and the doctor CLI use these names. Keep secrets on the server.
import { createSmsClient } from "@opencoredev/sms-sdk";
import { vonage } from "@opencoredev/sms-sdk/vonage";
const sms = createSmsClient({
adapters: [
vonage({
applicationId: process.env.VONAGE_APPLICATION_ID ?? "",
privateKey: process.env.VONAGE_PRIVATE_KEY ?? "",
from: "+15550100001",
region: "api-eu", // optional: "api" (default), "api-eu", "api-us", "api-ap"
}),
],
});
const result = await sms.send({ to: "+14155550123", body: "Your order has shipped." });
console.log(result.providerId); // the Vonage message_uuid
vonage() throws ConfigurationError at startup when credentials are empty or the private key is not a PEM. A PEM that is not an RSA PKCS#8 key makes each send fail with category auth. Each request gets a fresh RS256 token, valid for 15 minutes. baseUrl overrides region; fetch is optional.
Pick an auth mode:
JWT (applicationId, privateKey) |
Basic (apiKey, apiSecret) |
|
|---|---|---|
| Status and inbound webhooks | Yes | No |
Per-message webhookUrl |
Yes | No, throws UnsupportedFieldError |
| Numbers linked to an application | Work | Fail with 401 |
Use JWT auth for anything beyond fire-and-forget sends. With Basic auth, adapter.capabilities reports webhookUrlOverride, inbound, and deliveryReceipts as false.
Senders
| Sender | Write it as | Supported |
|---|---|---|
| Long code or toll-free number | "+15550100001" |
Yes. Sent without the +, as Vonage requires. |
| Alphanumeric sender ID | { senderId: "Acme" } |
Yes, where the destination country allows it |
| Short code | No | |
{ messagingService } |
No |
In the US, long codes need 10DLC registration, toll-free numbers must be verified (they then cover the US and Canada), and alphanumeric sender IDs are not allowed. Some other countries require registration through Vonage’s Global Sender ID portal; Vonage lists each country’s rules. SMS SDK does not check these rules and they have not been tested against live accounts. See Registration and consent.
Features
| Feature | Support |
|---|---|
MMS (mediaUrls) |
No |
Scheduling (sendAt) |
No |
Validity period (validityPeriodSec) |
20 to 604,800 seconds, sent as ttl |
Per-message webhookUrl |
JWT auth only, sent as webhook_url |
| Empty body | Not allowed |
| Native idempotency | No. Use an idempotency store. |
Provider options
Pass other Messages API SMS fields in providerOptions.vonage:
// Loading the adapter module adds the typed providerOptions.vonage entry.
import "@opencoredev/sms-sdk/vonage";
import { sms } from "./sms";
await sms.send({
to: "+14155550123",
body: "Your order has shipped.",
providerOptions: { vonage: { clientRef: "order-123", encodingType: "text" } },
});
| Option | Wire field | Type |
|---|---|---|
clientRef |
client_ref |
string, up to 100 characters, returned in every status webhook |
webhookVersion |
webhook_version |
"v0.1" | "v1". parseSmsWebhook reads v1. |
trustedRecipient |
trusted_recipient |
boolean. Skips Fraud Defender protections (Fraud Defender Premium only). |
encodingType |
sms.encoding_type |
"text" | "unicode" | "auto" |
contentId |
sms.content_id |
string, a regulatory ID some countries require |
entityId |
sms.entity_id |
string, a regulatory ID some countries require |
poolId |
sms.pool_id |
string, a Number Pool to send from. from is still sent and used if the pool cannot be. |
extra |
any other field | Record<string, string | number | boolean>, sent as is. A key like sms.new_field goes inside the sms object. |
Unknown keys and wrong types throw before any request. extra cannot set a field SMS SDK sets (message_type, channel, to, from, text, ttl, webhook_url, sms), nor failover, which would send further messages SMS SDK cannot track. Use sms.<name> keys instead of sms. Use trusted_recipient, not Vonage’s deprecated trusted_sender. See providerOptions for fallback behavior.
Webhooks
Vonage signs each webhook with an HS256 JWT in the Authorization header, using your signature secret. Pass that secret to the parser:
import { parseSmsWebhook, WebhookSignatureError } from "@opencoredev/sms-sdk/webhooks";
import { db } from "./db";
export async function POST(request: Request): Promise<Response> {
try {
const event = await parseSmsWebhook({
provider: "vonage",
request,
credentials: { signatureSecret: process.env.VONAGE_SIGNATURE_SECRET ?? "" },
});
if (!(await db.webhookEvents.insertIfNew(event.dedupeKey))) return new Response(null, { status: 200 });
if (event.type === "message.delivered" || event.type === "message.undelivered") {
await db.messages.updateStatus(event.providerId, event.type, event.errorCode);
} else if (event.type === "message.received") {
await db.inbox.save({ from: event.from, to: event.to, body: event.body, providerId: event.providerId });
}
return new Response(null, { status: 200 });
} catch (error) {
if (error instanceof WebhookSignatureError) return new Response("Invalid signature", { status: 401 });
throw error;
}
}
To point Vonage at your handler, enable signed webhooks on the Vonage application, then:
- Status: pass
webhookUrlper message, or set the Status URL on the application. - Inbound: link the number to the application and set its Inbound URL.
The parser accepts only HS256, rejects a token whose iat is more than 300 seconds from now (toleranceSec changes this), and compares any payload_hash with the SHA-256 of the exact raw body, so pass the original request. The URL is not signed, so proxies do not break verification. In tests, signedVonageRequest() from @opencoredev/sms-sdk/testing builds signed requests.
| Vonage status | SMS SDK event |
|---|---|
| inbound message | message.received, or recipient.opted_out / recipient.help for STOP and HELP keywords |
submitted |
message.sent |
delivered |
message.delivered |
rejected, undeliverable with error 1210, 1470, 1472, or 1480 to 1483 |
message.filtered |
rejected, undeliverable with any other error |
message.undelivered |
read and anything else |
unrecognized |
Vonage sends no queued status. It sends numbers without +; SMS SDK restores it, so from and to are E.164. dedupeKey is vonage:{message_uuid}:{status} or vonage:{message_uuid}:received. STOP and HELP come from keyword detection on the text; Vonage’s sms.keyword field is not read.
Limitations
- No MMS and no short codes. Use Twilio, Telnyx, or Plivo.
- No scheduling. Use Telnyx, Sinch, or Twilio with a Messaging Service.
- Basic auth has no webhooks and fails for numbers linked to an application.
- Error classification is inferred, so some sender or account problems may arrive as
requestand not fall back.
Error codes
SMS SDK reads the error code from the fragment of the problem type URL (…#1420, or …#throttled from Vonage’s generic error list) or from a numeric title. A listed code wins over the HTTP status.
| Vonage response | Category | Falls back |
|---|---|---|
Error 1000, 1241, or throttled |
rate_limited |
Yes, after retries |
| Error 1120 or 1420 | sender |
Yes |
| 402, or error 1060, 1080, 1160, 1290, or 1460 | account |
Yes |
| 401 or 403 without a listed code | auth |
Yes |
| Error 1170 or 1430 | recipient |
No |
| Error 1240 or 1476 | compliance |
Never |
| 422 and any other 4xx | request |
No |
| 429 without a throttling code | unknown | Never |
5xx, network error, timeout, or 2xx without message_uuid |
unknown | Never |
A 429 without a documented throttling code may come from a proxy, so it proves nothing and the outcome is unknown. error.provider.requestId holds the X-Request-Id header. See Fallback and retries for what each category does.
Official documentation
Checked on 2026-10-08. The full evidence list is in PROVIDERS.md.
- Messages API reference
- Messages API error codes
- Signed webhooks
- Authentication
- Validating inbound messages (payload_hash)
- Basic auth and linked numbers