Skip to content

Signing & verification

Every webhook Teler delivers is signed with HMAC-SHA256 using the shared secret configured on your Voice App or SIP Trunk. Verify the signature on every request: it’s the only way to know the call really came from Teler.

Each webhook request includes two extra headers:

HeaderExampleDescription
X-Teler-Timestamp1714201234Unix epoch seconds when the event was signed.
X-Teler-Signature8a92f1c4...The HMAC-SHA256 digest as a bare hex string.

The signature is computed as:

HMAC-SHA256(secret, "<X-Teler-Timestamp>.<raw_body>")

Where:

  • <X-Teler-Timestamp> is the value of the timestamp header as a string.
  • <raw_body> is the raw request body bytes, exactly as Teler sent them, with no parsing and no re-serializing.
  • secret is the value you set when creating the Voice App or SIP Trunk.
import hmac
import hashlib
import time
from flask import Flask, request, abort
WEBHOOK_SECRET = "<your secret>"
TOLERANCE_SECONDS = 300 # reject events older than 5 minutes
app = Flask(__name__)
def verify_teler_signature(raw_body: bytes, timestamp: str, signature: str) -> bool:
# 1. Reject stale events (replay protection)
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
# 2. Compute the expected signature
msg = f"{timestamp}.".encode() + raw_body
expected = hmac.new(WEBHOOK_SECRET.encode(), msg, hashlib.sha256).hexdigest()
# 3. Constant-time compare against the raw header value
return hmac.compare_digest(signature, expected)
@app.post("/webhook")
def webhook():
raw_body = request.get_data()
ts = request.headers.get("X-Teler-Timestamp", "")
sig = request.headers.get("X-Teler-Signature", "")
if not verify_teler_signature(raw_body, ts, sig):
abort(401)
event = request.get_json()
# ... enqueue event for async processing ...
return "", 200
MistakeFix
Using parsed JSON instead of the raw bodyThe signature is over the bytes as sent. Re-serializing changes whitespace and invalidates the signature.
Skipping the timestamp tolerance checkWithout it, a captured request can be replayed indefinitely. 5 minutes is a sensible window.
Using == instead of constant-time compareLeaks signature bytes via timing. Always use hmac.compare_digest (Python) or crypto.timingSafeEqual (Node).
Verifying after parsing/processingVerify first: reject before any mutation.

Rotating the webhook secret in the dashboard replaces the secret immediately: there is no dual-secret grace window. Deploy the new secret to your endpoint at the same time you rotate it, so verification never breaks. This needs brief coordination, but no old secret is accepted once the new one is set.