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

Twilio vs Vonage for developers: SMS APIs, webhooks, and errors compared

Twilio vs Vonage from the code side: how each SMS API sends, authenticates, signs webhooks, reports errors, and handles STOP, plus how to use both from one TypeScript client.

Most Twilio vs Vonage comparisons rank the two on price, channels, and review scores. If you’re choosing an SMS API for your own backend, the differences that cost you time are in the code: how a send is authenticated, what a failure looks like, how webhooks are signed, and what happens when someone replies STOP.

This post compares Twilio’s Programmable Messaging API with Vonage’s Messages API on those points, from the TypeScript side. It doesn’t list prices, because they depend on country, number type, and volume. Check Twilio’s and Vonage’s pricing pages.

SMS SDK, which this site documents, is an open-source TypeScript library with adapters for both providers. It isn’t a carrier. You still need an account with Twilio, Vonage, or both.

The short answer

Both send transactional SMS through a REST API, sell US long codes and toll-free numbers, and need the same US carrier registration (10DLC for long codes, verification for toll-free). For a developer, the main differences are:

  • Vonage has two SMS APIs. The legacy SMS API (rest.nexmo.com/sms/json) and the newer Messages API (api.nexmo.com/v1/messages) use different auth, response formats, and webhook signing. Pick the Messages API for new code, and check which one a tutorial uses before you copy it.
  • Twilio’s sends are form-encoded with Basic auth. Vonage’s Messages API takes JSON, ideally with an application JWT. Vonage’s Basic auth gets no webhooks, and it fails with 401 for a number linked to an application.
  • Webhook signing differs. Twilio signs the URL and form fields with HMAC-SHA1 and no timestamp. Vonage signs a JWT with HS256 and a timestamp, and its claims include a hash of the body.
  • Twilio offers more sender types and features. It has short codes, MMS, scheduling, and Messaging Services. Through the Messages API’s SMS channel, Vonage covers long codes, toll-free numbers, and alphanumeric sender IDs, with no MMS on that channel and no scheduling.
  • Twilio documents its errors more completely. Its error dictionary is machine-readable and says which HTTP 429s weren’t processed. Vonage lists error codes but not which HTTP status each one uses.

If you need MMS, short codes, or scheduled sends, Twilio fits better. If you only send text and want JSON requests with signed, timestamped webhooks, the Vonage Messages API is the cleaner surface. You can also use both. See Use both from one client.

Side by side

Twilio Programmable Messaging Vonage Messages API
Endpoint POST https://api.twilio.com/2010-04-01/Accounts/{AccountSid}/Messages.json POST https://api.nexmo.com/v1/messages, or regional api-eu, api-us, api-ap
Request body Form-encoded To, From, Body JSON message_type: "text", channel: "sms", to, from, text
Phone number format E.164 with + Digits without the +
Auth Basic: Account SID and Auth Token, or an API key and secret JWT signed with an application’s RSA private key, or Basic with the API key and secret (no webhooks)
Success 201 with a sid (SM…) and status: "queued", or accepted with a Messaging Service 202 with a message_uuid
Errors JSON with a numeric code, message, and more_info link RFC 7807 problem details. The code is in the type URL fragment (…#1420)
Sender types Long code, toll-free, short code, alphanumeric, Messaging Service Long code, toll-free, alphanumeric
MMS Yes Not on the SMS channel
Scheduling With a Messaging Service sender No
Validity period 1 to 36,000 seconds (ValidityPeriod) 20 to 604,800 seconds (ttl)
Send idempotency key None None

The sender and feature rows describe what SMS SDK’s adapters send through each API. Vonage sends MMS through a separate Messages API channel, which the adapter doesn’t use.

Sending: what a request looks like

A Twilio send is a form POST with Basic auth:

curl -X POST "https://api.twilio.com/2010-04-01/Accounts/$TWILIO_ACCOUNT_SID/Messages.json" \
  -u "$TWILIO_ACCOUNT_SID:$TWILIO_AUTH_TOKEN" \
  --data-urlencode "To=+14155550123" \
  --data-urlencode "From=+15550100001" \
  --data-urlencode "Body=Your order has shipped."

A Vonage Messages API send is JSON, with numbers written without the +. This one uses Basic auth to keep it short. For webhooks, sign a JWT with your application’s private key instead, as the Vonage guide shows:

curl -X POST "https://api.nexmo.com/v1/messages" \
  -u "$VONAGE_API_KEY:$VONAGE_API_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"message_type":"text","channel":"sms","to":"14155550123","from":"15550100001","text":"Your order has shipped."}'

In both cases, the success response means the provider accepted the message, not that it reached the phone. Delivery arrives later in a webhook. Why “accepted” isn’t “delivered” covers what to store in the meantime.

Neither API takes an idempotency key on the send. If a request times out, you can’t tell whether the provider created the message, and resending can text the person twice. Store your own key per message. Prevent duplicate sends shows how.

Webhooks: how each one is signed

This is where the two differ most, and where copied code most often fails.

Twilio puts a signature in X-Twilio-Signature: a Base64 HMAC-SHA1, keyed with your Auth Token, over the full public URL followed by every POST parameter sorted by name. That has two consequences:

  • The URL has to match exactly what Twilio called. Behind a proxy or a tunnel, rebuild it from the public URL, not from the request your server sees.
  • No timestamp is signed, so a captured request stays valid. Deduplicate every webhook by message SID and status.

Vonage sends Authorization: Bearer <JWT>, signed with HS256 using the signature secret from your dashboard. That isn’t your API secret. The token carries an iat timestamp and a payload_hash claim, the SHA-256 of the body. So:

  • The URL isn’t signed, so proxies don’t break verification.
  • You can reject old tokens. Vonage doesn’t document a window. SMS SDK uses 300 seconds.
  • You must hash the raw body bytes, before any JSON parsing.

Verify Twilio and Telnyx signatures by hand has dependency-free verifiers, and the Vonage guide has a hand-written HS256 check.

The status values differ too. Twilio reports queued, sending, sent, delivered, undelivered, and failed, among others, in no guaranteed order. Vonage’s Messages API reports submitted, delivered, rejected, and undeliverable, and sends no queued status at all. If your database stores provider statuses as is, a switch means migrating those values too.

Errors, opt-outs, and STOP

When a recipient has replied STOP, both providers refuse later sends to them from that sender: Twilio with error 21610, Vonage with error 1240 (“Illegal Number”). Your code should treat both as “never retry, never fall back to another provider”, and record the opt-out in your own suppression list so it survives a provider switch. SMS opt-out handling walks through it.

How the STOP reply reaches you differs:

  • Twilio adds an OptOutType field (STOP, START, HELP) to the inbound webhook when Advanced Opt-Out is turned on for a Messaging Service. Without it, you only get the message text.
  • Vonage’s inbound webhook carries the text and an sms.keyword field with the first word. You match the keywords yourself.

For other failures, Twilio’s error dictionary gives each code a meaning and says that a 429 with error 20429 wasn’t processed, so it’s safe to retry after backing off. Vonage documents 401, 402, and 422 and a list of error codes, but not which HTTP status each code uses, so some account or sender problems are hard to tell apart from a bad request. SMS SDK marks its Vonage adapter “partial” for that reason. The error tables list how each code is classified.

Use both from one client

You don’t have to pick one forever. With SMS SDK, each provider is an adapter, and your send code doesn’t name either:

import { createSmsClient } from "@opencoredev/sms-sdk";
import { twilio } from "@opencoredev/sms-sdk/twilio";
import { vonage } from "@opencoredev/sms-sdk/vonage";
import { db } from "./db";

export const sms = createSmsClient({
  adapters: [
    vonage({
      applicationId: process.env.VONAGE_APPLICATION_ID ?? "",
      privateKey: process.env.VONAGE_PRIVATE_KEY ?? "",
      from: "+15550100001",
    }),
    twilio({
      accountSid: process.env.TWILIO_ACCOUNT_SID ?? "",
      authToken: process.env.TWILIO_AUTH_TOKEN ?? "",
      from: "+15550100002",
    }),
  ],
  fallback: "on-known-rejection",
  // Checked before any provider request, so neither provider texts someone who replied STOP to the other.
  beforeSend: async ({ message }) =>
    (await db.suppressions.has(message.to)) ? { kind: "reject", reason: "opted_out" } : { kind: "allow" },
});

Vonage sends first. Twilio sends only when Vonage refuses the message for a reason tied to the account, such as bad credentials, a rate limit, or a sender problem. An opt-out, a bad number, or a timeout never falls back, so nobody gets the same text twice. Swap the order to make Twilio the primary. Each adapter sends from its own number, so recipients can see either one.

Each provider blocks only its own opted-out recipients, so someone who replies STOP to your Vonage number can still get a text through Twilio. The beforeSend check closes that gap: it reads your own suppression list, which the webhook handler below fills from both providers. In production, also give the client an idempotency store so a retried request doesn’t send twice.

Webhooks go through one parser, which verifies each scheme and returns the same event shape:

import { parseSmsWebhook, WebhookSignatureError } from "@opencoredev/sms-sdk/webhooks";
import { db } from "./db";

export async function handleSmsWebhook(provider: "twilio" | "vonage", request: Request): Promise<Response> {
  try {
    const event =
      provider === "twilio"
        ? await parseSmsWebhook({
            provider: "twilio",
            request,
            publicUrl: process.env.TWILIO_WEBHOOK_URL ?? "",
            credentials: { authToken: process.env.TWILIO_AUTH_TOKEN ?? "" },
          })
        : await parseSmsWebhook({
            provider: "vonage",
            request,
            credentials: { signatureSecret: process.env.VONAGE_SIGNATURE_SECRET ?? "" },
          });
    // Record the event key and its writes together, so a failed write rolls both back and the retry is handled.
    await db.transaction(async (tx) => {
      if (!(await tx.webhookEvents.insertIfNew(event.dedupeKey))) return;
      if (event.type === "recipient.opted_out") {
        await tx.suppressions.add(event.from, event.to);
      } else if (
        event.type === "message.delivered" ||
        event.type === "message.undelivered" ||
        event.type === "message.filtered"
      ) {
        await tx.messages.updateStatus(event.providerId, event.type, event.errorCode);
        // Twilio can report 21610 (recipient unsubscribed) on the status callback, after the send was accepted.
        if (event.errorCode === "21610" && event.to !== undefined) await tx.suppressions.add(event.to, event.from);
      }
    });
    // Twilio expects TwiML from an inbound webhook. Empty TwiML sends no automatic reply.
    if (provider === "twilio") return new Response("<Response></Response>", { headers: { "content-type": "text/xml" } });
    return new Response(null, { status: 200 });
  } catch (error) {
    if (error instanceof WebhookSignatureError) return new Response("Invalid signature", { status: 401 });
    throw error;
  }
}

Twilio’s undelivered and Vonage’s undeliverable both arrive as message.undelivered (or message.filtered when a carrier or spam filter blocked the text), and a STOP from either one arrives as recipient.opted_out.

A STOP webhook can be missed, so also record the provider’s own refusal. When a send fails because the recipient opted out (Twilio 21610, Vonage 1240), the refusal has category compliance and nothing falls back. With fallback on, it can also arrive wrapped in AllProvidersRejectedError, after the first provider refused for another reason, so check every refusal:

import { AllProvidersRejectedError, ProviderRejectedError, type E164 } from "@opencoredev/sms-sdk";
import { db } from "./db";
import { sms } from "./sms";

export async function sendText(to: E164, body: string): Promise<void> {
  try {
    await sms.send({ to, body });
  } catch (error) {
    const refusals =
      error instanceof AllProvidersRejectedError ? error.errors : error instanceof ProviderRejectedError ? [error] : [];
    if (refusals.some((refusal) => refusal.category === "compliance")) await db.suppressions.add(to);
    throw error;
  }
}

The Twilio guide and the Vonage guide have full handlers with START, HELP, and tests that run offline.

FAQ

Is the Vonage SMS API the same as the Messages API?

No. The SMS API is Vonage’s older API, inherited from Nexmo. It answers HTTP 200 with a status for each message part and uses its own signing scheme. The Messages API answers with one HTTP status per request and signs webhooks with a JWT. They aren’t interchangeable, so check which one a tutorial uses.

Which is easier to test without sending real texts?

Twilio’s test credentials only exist on accounts that created them in the legacy console, so plan on testing without the provider. With SMS SDK, memory() records simulated sends, and signedTwilioRequest() and signedVonageRequest() build correctly signed webhook requests for your tests. See Local testing.

Do I need 10DLC with both?

Yes, for application-to-person SMS from a US 10-digit number to US recipients. Registration lives in each provider account, so using both means registering with both. See 10DLC registration for developers.

Can I move from Twilio to Vonage without rewriting my code?

If your code calls the twilio package directly, the send path, error handling, and webhook routes all change. With an adapter layer, the switch is the adapter import, its credentials, and your account setup. Switch providers lists what moves and what doesn’t.

Last updated on