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-V2is 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.