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

Webhook security

How SMS SDK verifies Twilio, Telnyx, Plivo, and Vonage webhook signatures, and how to fix verification behind a proxy.

Your webhook URL is public, so anyone can post a fake “delivered” or “STOP” to it. parseSmsWebhook() checks the provider’s signature before it reads any field and throws WebhookSignatureError if the request is not authentic. The most common failure is a URL mismatch behind a proxy, covered below.

Respond to failures

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

export async function POST(request: Request): Promise<Response> {
  try {
    const event = await parseSmsWebhook({
      provider: "vonage",
      request,
      credentials: { signatureSecret: process.env.VONAGE_SIGNATURE_SECRET ?? "" },
    });
    console.log(event.type);
    return new Response(null, { status: 200 });
  } catch (error) {
    if (error instanceof WebhookSignatureError) {
      console.warn("Rejected webhook", error.providerName, error.reason);
      return new Response("Invalid signature", { status: 401 });
    }
    if (error instanceof WebhookPayloadError) {
      return new Response("Unexpected payload", { status: 400 });
    }
    throw error;
  }
}

WebhookSignatureError.reason says what failed:

reason Meaning
missing_signature The signature header or the Plivo nonce is absent.
invalid_signature The signature does not match. Usually a wrong credential or a URL mismatch.
stale_timestamp Telnyx or Vonage signed time is more than toleranceSec (default 300) from now.
body_hash_mismatch Twilio bodySHA256 or Vonage payload_hash does not match the body.
malformed_signature The signature or token cannot be decoded.
invalid_credentials The credential you passed is malformed, such as a Telnyx public key that is not 32 bytes of base64.

WebhookPayloadError means the request was authentic but lacks required fields.

How each provider signs

Provider Credential Scheme Replay window
Twilio { authToken } X-Twilio-Signature: Base64 HMAC-SHA1 of the URL plus sorted form parameters. For JSON bodies, the URL carries bodySHA256 and only the URL is signed. None. Twilio signs no timestamp.
Telnyx { publicKey } telnyx-signature-ed25519: Ed25519 over `` `${telnyx-timestamp} ${rawBody}` ``.
Plivo { authToken } X-Plivo-Signature-V2 (or X-Plivo-Signature-Ma-V2): Base64 HMAC-SHA256 of the URL without its query string plus X-Plivo-Signature-V2-Nonce. None. Plivo signs no timestamp, and the form parameters are not signed.
Vonage { signatureSecret } Authorization: Bearer <JWT>, HS256 with the signature secret. payload_hash, when present, is the SHA-256 of the body. toleranceSec on the JWT iat, default 300

Where to find each credential:

  • Twilio uses the Auth Token of the account that owns the number. API keys cannot verify webhooks.
  • Telnyx uses the base64 public key from Mission Control, under Keys & Credentials.
  • Plivo uses the Auth Token of the account or subaccount. X-Plivo-Signature-Ma-V2 is signed with the main account’s token.
  • Vonage uses the signature secret in the dashboard settings for the API key that signs your webhooks.

Twilio and Plivo sign no timestamp, so they have no replay protection. An attacker who captures a real request can send it again. Deduplicating on dedupeKey makes a replay harmless. See Duplicates and ordering.

Fix URL mismatches behind a proxy

Twilio and Plivo sign the exact URL they called, such as https://example.com/webhooks/sms/twilio. Behind a load balancer, tunnel, or platform proxy, your app often sees a different URL, such as http://10.0.0.5:3000/webhooks/sms/twilio. Verification then fails with invalid_signature.

Pass the public URL exactly as configured with the provider:

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

export async function POST(request: Request): Promise<Response> {
  const event = await parseSmsWebhook({
    provider: "twilio",
    request,
    publicUrl: "https://example.com/webhooks/sms/twilio",
    credentials: { authToken: process.env.TWILIO_AUTH_TOKEN ?? "" },
  });
  console.log(event.type);
  return new Response(null, { status: 200 });
}

If publicUrl has no query string, SMS SDK appends the request’s query string, so Twilio’s bodySHA256 and any parameters you added still verify. For Twilio it also tries the URL with and without the default port, :443 or :80.

The alternative is trustProxy: true, which builds the URL from X-Forwarded-Proto and X-Forwarded-Host. Use it only when a proxy you control sets those headers and strips any a client sent. Otherwise anyone can forge them. With neither option, SMS SDK uses request.url and ignores forwarded headers.

Telnyx and Vonage do not sign the URL, so these options do not exist for them.

Skipping verification in tests

unsafeSkipVerification: true parses without checking the signature. Use it only in unit tests. The signed request builders in @opencoredev/sms-sdk/testing are better because they exercise the real verification path. See Local testing.

The raw body

Signatures cover the raw bytes, so parseSmsWebhook() reads the body once with request.text(). Do not read or parse the body before calling it, and do not let a framework body parser consume it first. In Express and similar frameworks, convert the incoming request to a web Request with the raw body.

Lower-level helpers

To verify without parsing, /webhooks exports verifyTwilioSignature, verifyTelnyxSignature, verifyPlivoSignatureV2, and verifyVonageSignature, plus computeTwilioSignature and computePlivoSignatureV2. See Webhook events.

Next steps