Skip to content
SMS SDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

Vonage

Send and receive SMS through the Vonage Messages API with the SMS SDK vonage() adapter: Basic vs JWT auth, regions, webhooks, errors, and partial-support limits.

The vonage() adapter sends SMS through the Vonage Messages API on channel sms, and parses its JWT-signed status and inbound webhooks. It does not use the older Vonage SMS API at rest.nexmo.com/sms/json, whose responses and webhook signing work differently.

To compare providers, see the Providers overview.

Support status

Partial. The adapter works and passes the shared contract tests. Gaps in Vonage’s documentation limit it:

  • Vonage does not document which HTTP status each Messages API error code uses. SMS SDK classifies rejections from the documented 401, 402, and 422 statuses and from the error code in the response’s problem type or title. Unknown codes become request. A 429 counts as a rate limit only with a documented throttling code.
  • The API reference describes payload_hash in signed webhooks only as “a SHA-256 hash of the payload”. SMS SDK checks it against the exact bytes delivered. If Vonage ever hashes a re-serialized body instead, verification fails with body_hash_mismatch.
  • Vonage documents no freshness window for the webhook JWT. SMS SDK uses 300 seconds.
  • This adapter does not support short codes or MMS.

The same notes are in adapter.support.notes at runtime.

Installation

npm install @opencoredev/sms-sdk
pnpm add @opencoredev/sms-sdk
yarn add @opencoredev/sms-sdk
bun add @opencoredev/sms-sdk
nub add @opencoredev/sms-sdk
aube add @opencoredev/sms-sdk
import { vonage } from "@opencoredev/sms-sdk/vonage";

Environment variables

Variable Required Used for
VONAGE_API_KEY, VONAGE_API_SECRET For Basic auth Account API key and secret.
VONAGE_APPLICATION_ID, VONAGE_PRIVATE_KEY For JWT auth Vonage application ID and its RSA private key, as PKCS#8 PEM contents. Literal \n sequences are accepted.
VONAGE_FROM Yes Default sender: a Vonage number or an alphanumeric sender ID.
VONAGE_SIGNATURE_SECRET For webhooks Signature secret from the dashboard settings.

The examples and the doctor CLI use these names. The adapter itself reads no environment variables. Keep secrets and the private key on the server.

Basic usage

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",
    }),
  ],
});

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.

Authentication

Choose one:

JWT auth Basic auth
Options applicationId, privateKey apiKey, apiSecret
Status and inbound webhooks Yes No
Per-message webhookUrl Yes No
Numbers linked to an application Works Fails with 401

Vonage documents that Basic auth does not support webhooks and fails with 401 when the number is linked to an application. With Basic auth, the adapter reports webhookUrlOverride, inbound, and deliveryReceipts as false, and a webhookUrl throws UnsupportedFieldError. Use JWT auth for anything beyond fire-and-forget sends.

import { vonage } from "@opencoredev/sms-sdk/vonage";

const basic = vonage({
  apiKey: process.env.VONAGE_API_KEY ?? "",
  apiSecret: process.env.VONAGE_API_SECRET ?? "",
  from: { senderId: "Acme" },
  region: "api-eu",
});

console.log(basic.capabilities.deliveryReceipts); // false

With JWT auth, the adapter signs a fresh RS256 token for each request with the claims Vonage documents: application_id, iat, jti, and exp 15 minutes later. If the private key cannot be imported as an RSA PKCS#8 key, each send is rejected with category auth.

Configuration

Option Type Default Meaning
applicationId, privateKey string required for JWT Application ID and PKCS#8 PEM private key.
apiKey, apiSecret string required for Basic API key and secret.
from SmsFrom none Default sender.
region "api" | "api-eu" | "api-us" | "api-ap" "api" Host prefix: https://{region}.nexmo.com.
baseUrl string from region API origin. Overrides region.
fetch FetchLike global fetch Custom fetch.

API version and endpoint

  • POST https://{region}.nexmo.com/v1/messages, Messages API v1.
  • JSON body: message_type: "text", channel: "sms", to and from without the leading +, text, ttl (from validityPeriodSec), webhook_url (from webhookUrl), and any provider options.
  • Success: a 2xx response with message_uuid. A 2xx without it is an unknown outcome.

Senders

Sender Write Sent as
Long code or toll-free number "+15550100001" from, without +
Alphanumeric sender ID { senderId: "Acme" } from
Short code not supported
Messaging service not supported

Which senders work depends on the destination country.

Regions and countries

Vonage documents these rules for the United States and Canada:

Elsewhere, sender ID rules vary by country, and some countries require registration through Vonage’s Global Sender ID portal. Vonage’s country-specific features and restrictions list the rules for each country.

SMS SDK does not check these rules before sending, and they have not been tested against live accounts. See Sender registration and consent.

Capabilities

Capability JWT auth Basic auth
MMS No No
Scheduling No No
Validity period 20 to 604800 seconds 20 to 604800 seconds
Per-message webhookUrl Yes No
Delivery status webhooks Yes No
Inbound webhooks Yes No
Empty body Not allowed Not allowed
Native idempotency No No

Provider options

Pass the Messages API’s other SMS fields in providerOptions.vonage:

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",
    }),
  ],
});

await sms.send({
  to: "+14155550123",
  body: "Your order has shipped.",
  providerOptions: { vonage: { clientRef: "order-123", encodingType: "text" } },
});
Option Vonage 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 such as sms.new_field goes inside the sms object.

Unknown keys, wrong types, and extra keys that name a field SMS SDK sets throw before any request. The options go out only when the vonage adapter sends. See providerOptions for fallback and idempotency.

SMS SDK sets message_type, channel, to, from, text, ttl, webhook_url, and sms itself, so extra cannot. Use sms.<name> keys instead of sms. An sms.<name> key is also checked without its prefix, so it cannot reach a reserved or typed name. failover is blocked too: Vonage would send further messages, possibly on other channels, that SMS SDK cannot track. trusted_sender is deprecated by Vonage in favor of trusted_recipient.

Example: send and track delivery

import { createSmsClient, isE164, isSmsError } from "@opencoredev/sms-sdk";
import { vonage } from "@opencoredev/sms-sdk/vonage";

const from = process.env.VONAGE_FROM;
const to = process.env.SMS_EXAMPLE_TO;
if (!isE164(from) || !isE164(to)) {
  throw new Error("Set VONAGE_FROM and SMS_EXAMPLE_TO to E.164 numbers.");
}

const sms = createSmsClient({
  adapters: [
    vonage({
      applicationId: process.env.VONAGE_APPLICATION_ID ?? "",
      privateKey: process.env.VONAGE_PRIVATE_KEY ?? "",
      from,
    }),
  ],
});

try {
  const result = await sms.send({
    to,
    body: "Your order has shipped.",
    idempotencyKey: "order:123:shipped:v1",
    webhookUrl: "https://example.com/webhooks/sms/vonage",
  });
  console.log(`Vonage accepted ${result.providerId} (${result.delivery})`);
} catch (error) {
  if (isSmsError(error)) console.error(error.toJSON());
  throw error;
}

You see Vonage accepted <uuid> (queued). Acceptance is not delivery. Vonage posts status updates to the webhookUrl, covered in Delivery status webhooks.

Delivery and inbound webhooks

  • Status: pass webhookUrl per message, or set the Status URL on the Vonage application.
  • Inbound: link the number to the application and set its Inbound URL.
  • Webhooks require JWT auth on the sending side and signed webhooks enabled for the application.
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 (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;
  }
}

Status mapping: submitted becomes message.sent; delivered becomes message.delivered; rejected and undeliverable become message.undelivered, or message.filtered with error 1210, 1470, 1472, or 1480 to 1483. Vonage sends no queued status. read and other statuses become unrecognized.

Vonage 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. SMS SDK does not read Vonage’s sms.keyword field as an opt-out signal. STOP and HELP come from keyword detection on the text.

Signature verification

Vonage signs Messages API webhooks with Authorization: Bearer <JWT>, an HS256 token signed with your signature secret. SMS SDK checks that:

  • the HS256 signature is valid. Other algorithms are refused.
  • iat is within toleranceSec of now. The default is 300 seconds.
  • payload_hash, when present, equals the SHA-256 hex of the exact raw body. A re-serialized body fails, because different bytes can parse to the same JSON.

The URL is not signed, so proxies do not affect verification.

Error mapping

Vonage response Category Falls back
401 or 403 without a listed code auth Yes
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
Error 1170 or 1430 recipient No
Error 1240 or 1476 compliance Never
422 and any other 4xx request No
429 without one of those codes unknown Never
5xx, network error, timeout, 2xx without message_uuid unknown Never

SMS SDK reads the error code from the type URL fragment, such as …#1420 or …#throttled from Vonage’s generic error list, or from a numeric title. A 429 without a documented throttling code may come from a proxy, so it proves nothing and is unknown. error.provider.requestId holds the X-Request-Id header.

Limitations

  • No MMS and no short codes. Use Twilio, Telnyx, or Plivo.
  • No scheduling. Use Telnyx, 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 request and not fall back.
  • The legacy SMS API is not supported, and its webhooks do not verify with this parser.

Testing

Point vonage() at a fake API with mockFetch:

import { createSmsClient } from "@opencoredev/sms-sdk";
import { mockFetch } from "@opencoredev/sms-sdk/testing";
import { vonage } from "@opencoredev/sms-sdk/vonage";

const fake = mockFetch({ status: 202, body: { message_uuid: "aaaaaaaa-bbbb-4ccc-8ddd-0123456789ab" } });
const sms = createSmsClient({
  adapters: [vonage({ apiKey: "test", apiSecret: "test", from: "+15550100001", fetch: fake.fetch })],
});

await sms.send({ to: "+14155550123", body: "Hi" });
console.log(JSON.parse(fake.calls[0]?.body ?? "{}").to); // "14155550123"

Test your webhook handler with signedVonageRequest(). See Local testing.

The adapter’s own tests live in the SMS SDK repository. From packages/sms-sdk:

bun test test/contracts/vonage.test.ts test/webhooks/vonage.test.ts

The live test sends one real, billable message to each configured provider. It is skipped unless you opt in:

LIVE_SMS_TESTS=true LIVE_SMS_TO=+14155550123 LIVE_SMS_ALLOWLIST=+14155550123 \
VONAGE_API_KEY=... VONAGE_API_SECRET=... VONAGE_FROM=+15550100001 \
bun test test/integration/live.test.ts

Check your configuration first with npx @opencoredev/sms-sdk doctor --adapter vonage.

API reference

vonage(options: VonageOptions): SmsAdapter

type VonageOptions =
  | { applicationId: string; privateKey: string; from?: SmsFrom; region?: VonageRegion; baseUrl?: string; fetch?: FetchLike }
  | { apiKey: string; apiSecret: string; from?: SmsFrom; region?: VonageRegion; baseUrl?: string; fetch?: FetchLike };

type VonageRegion = "api" | "api-eu" | "api-us" | "api-ap";

Also exported from @opencoredev/sms-sdk/vonage: VONAGE_JWT_CAPABILITIES, VONAGE_BASIC_CAPABILITIES, VONAGE_SUPPORT_NOTES, VONAGE_PROVIDER_OPTIONS, buildVonageRequest(message), interpretVonageResponse(status, text, headers), vonageRejectionCategory(status, code), signVonageJwt({ applicationId, key, now }), and the types VonageOptions, VonageRegion, VonageBasicAuthOptions, VonageJwtAuthOptions, VonageMessageRequest, VonageSendOptions.

Official documentation

Checked on 2026-10-08. The full evidence list is in PROVIDERS.md.

Next steps