Verify Telnyx Webhook Signatures in Python
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_KEYis 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."