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