---
title: "Vonage"
description: "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](/providers/overview).

## 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`, channel `sms`). The older SMS API at `rest.nexmo.com/sms/json` is 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_hash` only 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 with `body_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

```package-install
npm install @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](/reference/doctor-cli) use these names. Keep secrets on the server.

```ts
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](https://developer.vonage.com/en/blog/what-you-need-to-know-about-10dlc) registration, toll-free numbers must be [verified](https://developer.vonage.com/en/tfn-registration/guides/register-a-tfn) (they then cover the US and Canada), and alphanumeric sender IDs are [not allowed](https://api.support.vonage.com/hc/en-us/articles/204017023-United-States-SMS-Features-and-Restrictions). Some other countries require registration through Vonage's [Global Sender ID portal](https://api.support.vonage.com/hc/en-us/articles/6791919802652-Global-Sender-ID-Registration-Guide); Vonage lists each country's [rules](https://api.support.vonage.com/hc/en-us/sections/200622473-Country-Specific-Features-and-Restrictions). SMS SDK does not check these rules and they have not been tested against live accounts. See [Registration and consent](/providers/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](/sending/idempotency). |

## Provider options

Pass other Messages API SMS fields in `providerOptions.vonage`:

```ts
// 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`](/reference/send#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:

```ts
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 `webhookUrl` per 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`](/testing/local-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 `request` and 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](/sending/fallback-and-retries) for what each category does.

## Official documentation

Checked on 2026-10-08. The full evidence list is in [PROVIDERS.md](https://github.com/opencoredev/sms-sdk/blob/main/packages/sms-sdk/PROVIDERS.md).

- [Messages API reference](https://developer.vonage.com/en/api/messages)
- [Messages API error codes](https://developer.vonage.com/en/api-errors/messages)
- [Signed webhooks](https://developer.vonage.com/en/messages/concepts/signed-webhooks)
- [Authentication](https://developer.vonage.com/en/getting-started/concepts/authentication)
- [Validating inbound messages (payload_hash)](https://developer.vonage.com/en/blog/validating-inbound-messages-from-the-vonage-messages-api-dr)
- [Basic auth and linked numbers](https://api.support.vonage.com/hc/en-us/articles/4412210010644)

## Next steps

- [Receive status and inbound webhooks](/receiving/webhooks)
- [Webhook security](/receiving/webhook-security)
- [Test locally](/testing/local-testing)
