Skip to main content

Security & Signatures

Every Zyphr webhook delivery is signed with HMAC-SHA256. Always verify signatures before processing webhook payloads.

Signature Formats​

Zyphr supports two signature formats. The header_format setting on each webhook controls which headers are sent.

The default format follows the Standard Webhooks specification.

Headers sent:

HeaderDescription
webhook-idStable message ID (msg_...) for deduplication
webhook-timestampUnix timestamp in seconds
webhook-signatureSignature in v1,{base64_hmac} format

Signing algorithm:

signed_content = "{webhook-id}.{webhook-timestamp}.{body}"
signature = base64(hmac_sha256(secret_bytes, signed_content))
header_value = "v1," + signature

The secret bytes are derived from the webhook secret by stripping the whsec_ prefix and decoding the remaining hex string.

Legacy Format​

The legacy format uses hex-encoded signatures with X-Zyphr-* headers.

Headers sent:

HeaderDescription
X-Zyphr-SignatureSignature in sha256={hex_hmac} format
X-Zyphr-TimestampUnix timestamp in seconds

Signing algorithm:

signed_content = "{timestamp}.{body}"
signature = hex(hmac_sha256(secret, signed_content))
header_value = "sha256=" + signature

Migrating Between Formats​

Set header_format to "both" to receive both Standard Webhooks and legacy headers simultaneously. This lets you update your verification code before switching fully to one format.

Timestamp Verification​

Always verify the timestamp is within an acceptable tolerance (recommended: 5 minutes) to prevent replay attacks:

current_time = now()
tolerance = 300 // 5 minutes in seconds
if abs(current_time - webhook_timestamp) > tolerance:
reject("Timestamp outside tolerance")

Verification Examples​

TypeScript / Node.js​

import crypto from 'crypto';

function verifyStandardWebhook(
payload: string,
headers: {
'webhook-id': string;
'webhook-timestamp': string;
'webhook-signature': string;
},
secret: string
): boolean {
const msgId = headers['webhook-id'];
const timestamp = parseInt(headers['webhook-timestamp'], 10);
const signatures = headers['webhook-signature'];

// Check timestamp tolerance (5 minutes)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) {
return false;
}

// Compute expected signature
const signedContent = `${msgId}.${timestamp}.${payload}`;
const secretBytes = Buffer.from(
secret.startsWith('whsec_') ? secret.slice(6) : secret,
'hex'
);
const expected = crypto
.createHmac('sha256', secretBytes)
.update(signedContent)
.digest('base64');

// Compare signatures (supports multiple signatures separated by spaces)
for (const sig of signatures.split(' ')) {
const sigValue = sig.slice(3); // Remove "v1," prefix
if (
sigValue.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sigValue), Buffer.from(expected))
) {
return true;
}
}

return false;
}

Python​

import hashlib
import hmac
import base64
import time

def verify_standard_webhook(payload: str, headers: dict, secret: str) -> bool:
msg_id = headers.get("webhook-id", "")
timestamp = headers.get("webhook-timestamp", "")
signature = headers.get("webhook-signature", "")

# Check timestamp tolerance (5 minutes)
now = int(time.time())
if abs(now - int(timestamp)) > 300:
return False

# Compute expected signature
signed_content = f"{msg_id}.{timestamp}.{payload}"
secret_hex = secret.removeprefix("whsec_")
secret_bytes = bytes.fromhex(secret_hex)
expected = base64.b64encode(
hmac.new(secret_bytes, signed_content.encode(), hashlib.sha256).digest()
).decode()

# Compare signatures
for sig in signature.split(" "):
sig_value = sig.removeprefix("v1,")
if hmac.compare_digest(sig_value, expected):
return True

return False

Go​

package webhook

import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"math"
"strconv"
"strings"
"time"
)

func VerifyStandardWebhook(payload string, msgID, timestamp, signature, secret string) bool {
// Check timestamp tolerance (5 minutes)
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
now := time.Now().Unix()
if math.Abs(float64(now-ts)) > 300 {
return false
}

// Compute expected signature
signedContent := msgID + "." + timestamp + "." + payload
secretHex := strings.TrimPrefix(secret, "whsec_")
secretBytes, err := hex.DecodeString(secretHex)
if err != nil {
return false
}
mac := hmac.New(sha256.New, secretBytes)
mac.Write([]byte(signedContent))
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))

// Compare signatures
for _, sig := range strings.Split(signature, " ") {
sigValue := strings.TrimPrefix(sig, "v1,")
if hmac.Equal([]byte(sigValue), []byte(expected)) {
return true
}
}

return false
}

PHP​

function verifyStandardWebhook(
string $payload,
string $msgId,
string $timestamp,
string $signature,
string $secret
): bool {
// Check timestamp tolerance (5 minutes)
$now = time();
if (abs($now - intval($timestamp)) > 300) {
return false;
}

// Compute expected signature
$signedContent = "{$msgId}.{$timestamp}.{$payload}";
$secretHex = str_starts_with($secret, 'whsec_')
? substr($secret, 6)
: $secret;
$secretBytes = hex2bin($secretHex);
$expected = base64_encode(
hash_hmac('sha256', $signedContent, $secretBytes, true)
);

// Compare signatures
foreach (explode(' ', $signature) as $sig) {
$sigValue = substr($sig, 3); // Remove "v1," prefix
if (hash_equals($sigValue, $expected)) {
return true;
}
}

return false;
}

Secret Management​

Secret Format​

Webhook secrets use the format whsec_{64_hex_chars} — a 32-byte random value encoded as hex with a whsec_ prefix.

Rotating Secrets​

Rotate a webhook's secret at any time:

curl -X POST https://api.zyphr.dev/v1/webhooks/YOUR_WEBHOOK_ID/rotate-secret \
-H "Authorization: Bearer YOUR_API_KEY"

The response returns the new secret. The old secret is immediately invalidated — update your verification code before the next delivery.

tip

To rotate without downtime, temporarily set header_format to "both" so you can verify against both signature formats while transitioning.

Security Checklist​

  • Always verify signatures before processing payloads
  • Use timing-safe comparison functions (e.g., crypto.timingSafeEqual, hmac.compare_digest)
  • Verify timestamps are within 5 minutes to prevent replay attacks
  • Use HTTPS for your webhook endpoint
  • Keep your signing secret confidential — never expose it in client-side code
  • Rotate secrets periodically or immediately if compromised