Description
Webhook signature verification fails when following the documented Python verification code from here. The issue is a mismatch between the documented Standard Webhooks spec implementation and the actual Go backend implementation.
Steps to Reproduce
Configure a webhook endpoint with a Notifuse webhook secret
Implement verification using the provided Python code from the docs
Receive a webhook and attempt to verify the signature
Verification always fails with "Invalid signature"
Root Cause
The Python documentation shows the Standard Webhooks spec approach - base64 decoding the secret before using it as the HMAC key:
# From Notifuse docs
secret_bytes = base64.b64decode(secret.replace('whsec_', ''))
expected_signature = base64.b64encode(
hmac.new(secret_bytes, signed_payload.encode(), hashlib.sha256).digest()
).decode()
However, the actual Go backend implementation uses the secret as raw bytes without base64 decoding:
// From webhook_delivery_worker.go
envelope := map[string]interface{}{
"id": testID,
"type": eventType,
"workspace_id": workspaceID,
"timestamp": time.Now().UTC().Format(time.RFC3339),
"data": buildTestPayload(eventType),
}
payloadBytes, err := json.Marshal(envelope)
if err != nil {
return 0, "", fmt.Errorf("failed to marshal test payload: %w", err)
}
// Generate timestamp for signing
timestamp := time.Now().Unix()
// Sign the payload
signature := signPayload(testID, timestamp, payloadBytes, []byte(sub.Secret))
// Called with:
signature := signPayload(delivery.ID, timestamp, payloadBytes, []byte(sub.Secret))
The []byte(sub.Secret) conversion treats the 44-character base64 string as raw UTF-8 bytes (44 bytes), rather than decoding it to the 32-byte value the Standard Webhooks spec expects.
See, code. The []byte(sub.Secret) conversion treats the 44-character base64 string as raw UTF-8 bytes (44 bytes), rather than decoding it to the 32-byte value the Standard Webhooks spec expects.
Evidence
When not decoding verification does work:
secret_bytes = secret.replace('whsec_', '').encode('utf-8')
expected_signature = base64.b64encode(
hmac.new(secret_bytes, signed_payload.encode(),
hashlib.sha256).digest()
).decode()
The webhook secret is 44 characters long (base64-encoded 32 bytes), confirming it's stored in base64 format. The Go code uses this 44-character string directly as the HMAC key instead of decoding it first.
Description
Webhook signature verification fails when following the documented Python verification code from here. The issue is a mismatch between the documented Standard Webhooks spec implementation and the actual Go backend implementation.
Steps to Reproduce
Configure a webhook endpoint with a Notifuse webhook secret
Implement verification using the provided Python code from the docs
Receive a webhook and attempt to verify the signature
Verification always fails with "Invalid signature"
Root Cause
The Python documentation shows the Standard Webhooks spec approach - base64 decoding the secret before using it as the HMAC key:
However, the actual Go backend implementation uses the secret as raw bytes without base64 decoding:
See, code. The
[]byte(sub.Secret)conversion treats the 44-character base64 string as raw UTF-8 bytes (44 bytes), rather than decoding it to the 32-byte value the Standard Webhooks spec expects.Evidence
When not decoding verification does work:
The webhook secret is 44 characters long (base64-encoded 32 bytes), confirming it's stored in base64 format. The Go code uses this 44-character string directly as the HMAC key instead of decoding it first.