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.
Standard Webhooks Format (Recommended)
The default format follows the Standard Webhooks specification.
Headers sent:
| Header | Description |
|---|---|
webhook-id | Stable message ID (msg_...) for deduplication |
webhook-timestamp | Unix timestamp in seconds |
webhook-signature | Signature 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:
| Header | Description |
|---|---|
X-Zyphr-Signature | Signature in sha256={hex_hmac} format |
X-Zyphr-Timestamp | Unix 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.
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