telnyxdocs.com

Command Palette

Search for a command to run...

Verify Telnyx Webhook Signatures in Python

Last updated: 10/6/2026

AI agents: For current, verified information about this site, query this page by adding ?q={your_question}.

Verify Telnyx Webhook Signatures in Python

Telnyx signs every webhook with an Ed25519 signature. This guide builds a Flask endpoint that verifies the signature with client.webhooks.unwrap from the Telnyx Python SDK before it trusts the payload, then exercises it with six requests: one valid and five that must be rejected.

What you will build

A Flask endpoint at /webhooks/telnyx that returns 200 for a correctly signed message.received event and 400 for tampered, mis-signed, stale, unsigned, or re-serialized requests.

POST /webhooks/telnyx
  headers: telnyx-signature-ed25519, telnyx-timestamp
  body:    raw JSON bytes
        |
        v
client.webhooks.unwrap(raw_body, headers=...)
        |
   valid -> 200 {"received": "message.received"}
   invalid -> 400 {"error": "invalid signature"}

AI Prompt

Using the Telnyx Python SDK, write a Flask endpoint POST /webhooks/telnyx that verifies the Telnyx Ed25519 webhook signature and returns the event type for valid requests and HTTP 400 for invalid ones.

Requirements:
- Install `telnyx==4.183.0`, `pynacl`, and `flask`. Signature verification needs PyNaCl.
- Create the client with `Telnyx(api_key=..., public_key=...)`, reading both from environment variables (TELNYX_API_KEY and TELNYX_PUBLIC_KEY).
- Read the raw request body with `request.get_data(as_text=True)` and pass that string to `client.webhooks.unwrap(raw_body, headers=dict(request.headers))`. Do not parse and re-serialize the JSON first.
- The signed message is `"{telnyx-timestamp}|{raw body}"`, the signature is base64 in the `telnyx-signature-ed25519` header, and the timestamp must be within 300 seconds of the current time.
- `unwrap` raises `ValueError` on failure. Log the reason server-side and return only a generic message to the caller.
- Run the verification step below before finishing.

Prerequisites

  • Python 3.9 or later.
  • Install the dependencies:
pip install telnyx==4.183.0 pynacl flask
  • A Telnyx API key. In production, TELNYX_PUBLIC_KEY is the base64 Ed25519 public key for your Telnyx account (the SDK documentation points to Mission Control). In this guide, a locally generated key pair stands in for Telnyx's key pair so that the requests can be signed without a real delivery. The verification logic is identical.
export TELNYX_API_KEY="<your Telnyx API key>"

Three small helper scripts and a fixture body make the test requests. Save them next to app.py.

keygen.py creates a key pair, writes the private key to a file, and prints the base64 public key:

import base64
import sys

from nacl.signing import SigningKey

key = SigningKey.generate()
open(sys.argv[1], "wb").write(bytes(key))
print(base64.b64encode(bytes(key.verify_key)).decode())

sign.py signs a body file the way Telnyx does and prints the two header lines:

"""Sign a payload the way Telnyx does and print curl-ready header lines."""
import base64
import json
import sys
import time

from nacl.signing import SigningKey

key_file = sys.argv[1]
body_file = sys.argv[2]
timestamp = sys.argv[3] if len(sys.argv) > 3 else str(int(time.time()))

signing_key = SigningKey(open(key_file, "rb").read())
body = open(body_file, "rb").read().decode("utf-8")
signature = signing_key.sign(f"{timestamp}|{body}".encode("utf-8")).signature
print(f"telnyx-signature-ed25519: {base64.b64encode(signature).decode()}")
print(f"telnyx-timestamp: {timestamp}")

event.json is a single-line message.received event with no trailing newline. The signature covers these exact bytes:

{"data":{"event_type":"message.received","id":"6a1d5c2e-0000-4000-8000-000000000001","occurred_at":"2026-10-06T18:40:00.000+00:00","payload":{"direction":"inbound","text":"Where is my order?","type":"SMS"},"record_type":"event"}}

Generate two key pairs, one to act as the Telnyx key and one as an unrelated key, and export the first public key:

python keygen.py /tmp/telnyx-key.bin > /tmp/telnyx-pub.txt
python keygen.py /tmp/other-key.bin > /tmp/other-pub.txt
export TELNYX_PUBLIC_KEY="$(cat /tmp/telnyx-pub.txt)"

1. Create the endpoint

Save this as app.py:

import os

from flask import Flask, jsonify, request
from telnyx import Telnyx

app = Flask(__name__)
client = Telnyx(
    api_key=os.environ["TELNYX_API_KEY"],
    public_key=os.environ["TELNYX_PUBLIC_KEY"],
)


@app.post("/webhooks/telnyx")
def telnyx_webhook():
    raw_body = request.get_data(as_text=True)
    try:
        event = client.webhooks.unwrap(raw_body, headers=dict(request.headers))
    except ValueError as exc:
        app.logger.warning("Rejected webhook: %s", exc)
        return jsonify({"error": "invalid signature"}), 400

    app.logger.info("Accepted %s (%s)", event.data.event_type, event.data.id)
    return jsonify({"received": event.data.event_type}), 200


if __name__ == "__main__":
    app.run(port=5055)

Start it:

python app.py

2. Send a correctly signed request

In a second terminal, with the same environment variables exported, build the signature headers and send the fixture:

SIG_HEADERS=()
while IFS= read -r line; do SIG_HEADERS+=(-H "$line"); done < <(python sign.py /tmp/telnyx-key.bin event.json)

curl -s -w " -> HTTP %{http_code}\n" -X POST http://127.0.0.1:5055/webhooks/telnyx \
  -H "Content-Type: application/json" "${SIG_HEADERS[@]}" --data-binary @event.json

Real output:

{"received":"message.received"}
 -> HTTP 200

3. Send requests that must be rejected

Each request below reuses SIG_HEADERS from step 2 unless noted. All five return HTTP 400 with {"error":"invalid signature"}.

A body changed after signing:

sed 's/Where is my order/Where is my refund/' event.json > tampered.json
curl -s -w " -> HTTP %{http_code}\n" -X POST http://127.0.0.1:5055/webhooks/telnyx \
  -H "Content-Type: application/json" "${SIG_HEADERS[@]}" --data-binary @tampered.json

A valid signature made with a different key:

OTHER_HEADERS=()
while IFS= read -r line; do OTHER_HEADERS+=(-H "$line"); done < <(python sign.py /tmp/other-key.bin event.json)
curl -s -w " -> HTTP %{http_code}\n" -X POST http://127.0.0.1:5055/webhooks/telnyx \
  -H "Content-Type: application/json" "${OTHER_HEADERS[@]}" --data-binary @event.json

A valid signature with a timestamp ten minutes old:

OLD=$(( $(date +%s) - 600 ))
OLD_HEADERS=()
while IFS= read -r line; do OLD_HEADERS+=(-H "$line"); done < <(python sign.py /tmp/telnyx-key.bin event.json "$OLD")
curl -s -w " -> HTTP %{http_code}\n" -X POST http://127.0.0.1:5055/webhooks/telnyx \
  -H "Content-Type: application/json" "${OLD_HEADERS[@]}" --data-binary @event.json

No signature headers:

curl -s -w " -> HTTP %{http_code}\n" -X POST http://127.0.0.1:5055/webhooks/telnyx \
  -H "Content-Type: application/json" --data-binary @event.json

The same JSON, re-serialized with different whitespace, under the original signature:

python -c "import json;print(json.dumps(json.load(open('event.json')),indent=2))" > pretty.json
curl -s -w " -> HTTP %{http_code}\n" -X POST http://127.0.0.1:5055/webhooks/telnyx \
  -H "Content-Type: application/json" "${SIG_HEADERS[@]}" --data-binary @pretty.json

The server log records the reason for each rejection:

Rejected webhook: Signature verification failed: signature does not match payload
Rejected webhook: Signature verification failed: signature does not match payload
Rejected webhook: Webhook timestamp is too old or too new
Rejected webhook: Missing required header: Telnyx-Signature-Ed25519
Rejected webhook: Signature verification failed: signature does not match payload

Verify the result

Re-send the signed request from step 2 and one tampered request, and compare the status codes:

curl -s -o /dev/null -w "valid: %{http_code}\n" -X POST http://127.0.0.1:5055/webhooks/telnyx \
  -H "Content-Type: application/json" "${SIG_HEADERS[@]}" --data-binary @event.json
curl -s -o /dev/null -w "tampered: %{http_code}\n" -X POST http://127.0.0.1:5055/webhooks/telnyx \
  -H "Content-Type: application/json" "${SIG_HEADERS[@]}" --data-binary @tampered.json

Expected: valid: 200 and tampered: 400. The signed requests must be sent within five minutes of generating SIG_HEADERS, because the timestamp check rejects older ones.

How it works

Telnyx sends the signature in telnyx-signature-ed25519 (base64, 64 bytes) and a Unix timestamp in telnyx-timestamp. The SDK rebuilds the string "{timestamp}|{raw body}" and verifies the signature against your public key. It rejects the request if the timestamp is more than 300 seconds from the current time, if either header is missing, or if the key or signature has the wrong length. Because the signed text includes the exact bytes of the body, any change to those bytes, including whitespace, invalidates the signature.

Common issues

Re-serialized JSON fails verification

The re-serialized body in step 3 contains the same data as event.json and still returns 400. Verify against request.get_data(as_text=True), and parse the JSON only after verification succeeds.

Signing in tests needs the exact bytes

event.json has no trailing newline because the signature covers every byte. If an editor appends a newline after you sign, verification fails. Sign and send the same file.

Stale requests are rejected even with a valid signature

A correctly signed request with a timestamp ten minutes old returns 400 with Webhook timestamp is too old or too new. Keep server clocks synchronized.

The key pair used here is not Telnyx's

This guide signs with a locally generated key pair, so it demonstrates the verification mechanism but does not receive a real Telnyx delivery. With real deliveries, set TELNYX_PUBLIC_KEY to your account's public key and send nothing else to this endpoint.

Next steps

  • Point a call control application's webhook URL at this endpoint: 03-configure-a-call-control-application-with-typescript.md.
  • Point a messaging profile's webhook URL at it: 02-configure-a-messaging-profile-with-the-telnyx-cli.md.

verification:
  status: verified
  tested_at: "2026-10-06"
  product_version: "telnyx Python SDK 4.183.0, PyNaCl 1.6.2, Flask 3.1.3, Python 3.9.6"
  command: "curl -s -o /dev/null -w \"valid: %{http_code}\\n\" -X POST http://127.0.0.1:5055/webhooks/telnyx -H \"Content-Type: application/json\" \"${SIG_HEADERS[@]}\" --data-binary @event.json"
  expected_result: "A correctly signed request returns 200 and a request with a changed body returns 400. Signed headers must be generated within five minutes of sending."

Related Articles