Signature Verification (v2)

Header format

When a webhook has authentication enabled, every delivery includes a TelegraMD-Signature header.

v2 format (current):

TelegraMD-Signature: t=1748476800,sha256=base64EncodedHMAC==

v1 format (legacy):

TelegraMD-Signature: sha256=base64EncodedHMAC==

The presence of t= at the start of the header value identifies v2.


How v2 signatures are computed

signedContent = "{t}.{rawJsonBody}"
signature     = base64( HMAC-SHA256(signingSecret, signedContent) )
header        = "t={t},sha256={signature}"

Where:

  • t is a Unix timestamp in seconds (integer)
  • rawJsonBody is the JSON string signed server-side (JSON.stringify(payload)) before the request is sent — in practice this matches the request body byte-for-byte, but it comes from a separate serialization step rather than being read back off the wire. Verify against the raw body you received, before your own JSON parser touches it
  • signingSecret is the 64-character hex secret shown at webhook creation or secret rotation

Replay protection

t gives your server the information it needs to reject replayed deliveries — TelegraMD does not enforce a freshness window on your behalf, since TelegraMD is the sender, not the receiver, of these webhooks. Your server must check it independently, as shown below.

Recommended: reject any request where now - t > 5 minutes (300 seconds).

Recommended check:

import time, hmac, hashlib, base64

def verify(secret, body_bytes, signature_header):
    # Parse header
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    t   = int(parts["t"])
    sig = parts["sha256"]

    # Replay guard
    if abs(time.time() - t) > 300:
        return False

    # Recompute
    signed  = f"{t}.{body_bytes.decode('utf-8')}"
    expected = base64.b64encode(
        hmac.new(secret.encode(), signed.encode(), hashlib.sha256).digest()
    ).decode()

    return hmac.compare_digest(sig, expected)

Critical: Always use hmac.compare_digest (or equivalent constant-time compare) to prevent timing attacks. Never use == to compare the signature strings.


How v1 signatures are computed (legacy)

signedContent = rawJsonBody
signature     = base64( HMAC-SHA256(signingSecret, signedContent) )
header        = "sha256={signature}"

v1 has no timestamp and therefore no replay protection. Migrate to v2 to gain replay resistance.


Secret rotation

Use POST /v2/webhooks/{webhook}/actions/rotateSecret to generate a new signing secret.

Update your verification code immediately after rotating — there is no grace period for signature verification. From the moment you rotate, every delivery is signed with the new secret only. If your server is still checking signatures against the old secret, verification will fail on every delivery until you update it.

The 24-hour window that follows rotation applies to one thing only: undoing the rotation. If you rotated by mistake or aren't ready yet, call POST /v2/webhooks/{webhook}/actions/revertSecret within 24 hours to restore the previous secret. After 24 hours, the previous secret is discarded and can no longer be recovered.

The new secret is returned once in the API response and never stored in plaintext after that. If you lose it, rotate again.


v1 → v2 signature migration checklist

  • Detect the header format: t= prefix → v2; sha256= only → v1
  • For v2: extract t and validate the replay window before verifying the HMAC
  • Always read body_bytes before your JSON parser — parsers may not preserve key order
  • Use timing-safe comparison for the HMAC values
  • After updating your consumer, call the upgrade endpoint to switch the webhook to v2 payloads