Verifying Webhooks from WaveSpeedAI
To ensure the authenticity and integrity of webhook events sent by WaveSpeedAI, we strongly recommend verifying every incoming webhook request. This protects your system from spoofed or replayed requests and ensures that only legitimate events are processed.
Why Verify Webhooks?
Webhook endpoints are publicly accessible and may receive requests from anyone. Without verification, a malicious actor could send forged requests to your endpoint, which might trigger unintended or insecure behavior in your system.
By verifying WaveSpeedAI webhook signatures, you can:
- Ensure the event came from WaveSpeedAI
- Detect any payload tampering
- Prevent replay attacks by checking timestamps
Getting Your Webhook Secret
Owners and admins can reveal and copy the secret in the Webhook secret section of the API Keys page. The link opens that section for you.
You can also retrieve it by calling the WaveSpeedAI API:
curl --fail-with-body --connect-timeout 10 --max-time 30 \
--request GET 'https://api.wavespeed.ai/api/v3/webhook/secret' \
--header "Authorization: Bearer ${WAVESPEED_API_KEY}"The secret looks like whsec_... and is used to sign every webhook request. Keep it server-side and never commit it to source control. API keys in your organization can retrieve it, so protect your API keys the same way.
For actions that matter, such as billing your own users or publishing output, treat the webhook as a notification: fetch the prediction with GET /api/v3/predictions/{id}/result using your API key and act on that response.
Verify with a Standard Webhooks Library
Webhooks requested with the standard_webhook query parameter are signed according to the Standard Webhooks specification, so you can verify them with an official library. Pass the secret exactly as returned, including the whsec_ prefix, together with the raw request body and the request headers.
Python
pip install standardwebhooksimport os
from standardwebhooks.webhooks import Webhook
wh = Webhook(os.environ["WAVESPEED_WEBHOOK_SECRET"])
def verify(raw_body: bytes, headers) -> dict:
# raw_body: the unparsed request body; headers: the incoming request headers.
# Raises an exception when the signature or timestamp is invalid.
return wh.verify(raw_body, {
"webhook-id": headers["webhook-id"],
"webhook-timestamp": headers["webhook-timestamp"],
"webhook-signature": headers["webhook-signature"],
})JavaScript (Node.js)
npm install standardwebhooksconst express = require('express');
const { Webhook } = require('standardwebhooks');
const wh = new Webhook(process.env.WAVESPEED_WEBHOOK_SECRET);
const app = express();
// Keep the raw body: the signature covers the exact bytes that were sent.
app.post('/webhooks/wavespeed', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = wh.verify(req.body.toString(), req.headers);
} catch (err) {
return res.status(400).send('Invalid signature');
}
res.sendStatus(200);
// Process event asynchronously.
});Go
go get github.com/standard-webhooks/standard-webhooks/librariespackage main
import (
"io"
"net/http"
"os"
standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"
)
func main() {
wh, err := standardwebhooks.NewWebhook(os.Getenv("WAVESPEED_WEBHOOK_SECRET"))
if err != nil {
panic(err)
}
http.HandleFunc("/webhooks/wavespeed", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil || wh.Verify(body, r.Header) != nil {
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
// Process body asynchronously.
})
http.ListenAndServe(":8080", nil)
}Verifying without a library
Each request carries these headers:
webhook-id: <prediction_id>
webhook-timestamp: <unix_timestamp>
webhook-signature: v1,<base64_signature>- Remove the
whsec_prefix from the secret and base64-decode the rest. The decoded bytes are the HMAC key. - Compute
HMAC_SHA256(key, "{webhook-id}.{webhook-timestamp}.{raw_body}")and base64-encode the result. webhook-signaturecan contain several space-separated signatures, such asv1,<sig1> v1,<sig2>. Accept the request if anyv1signature matches yours, using a constant-time comparison.- Reject requests whose timestamp is more than 5 minutes away from your current time.
Rotating the Secret
Owners and admins can rotate the secret on the API Keys page. A new secret is created immediately and the previous one stays valid for 24 hours.
During those 24 hours, webhooks requested with standard_webhook carry signatures from both secrets, so your server keeps verifying whether it uses the previous or the new secret. Switch to the new secret at any time within the window. Once you have switched, you can end the previous secret early from the same page.
You can rotate up to 3 times in 24 hours.
Troubleshooting
- Signature verification fails: Verify against the raw request body. Parsing and re-serializing JSON changes whitespace or field order and breaks the signature.
- Secret format: Pass the full
whsec_...secret to the library. When verifying without a library, base64-decode the part afterwhsec_and use the decoded bytes as the key. - Timestamp expired: The
webhook-timestampmust be within 5 minutes of your server’s clock. Check that your server time is synchronized. - Using the
webhookparameter: Webhooks requested withwebhookuse a different signature format and are not accepted by Standard Webhooks libraries. Usestandard_webhook, or see the legacy format below.
Legacy signature format (webhook query parameter)
Webhooks requested with the webhook query parameter keep the signature format below. Existing integrations do not need to change. New integrations should use standard_webhook.
Important: For this format, remove the whsec_ prefix and use the remaining string as the HMAC key. Do not base64 decode it.
After a rotation, webhooks requested with webhook stay signed with the previous secret until it expires or you end it early, and then use the new secret. To switch without interruption, let your verification accept both secrets during that window.
Signature Header Format
Webhooks requested with the webhook query parameter include the following headers:
webhook-id: prediction_id webhook-timestamp: current_unix_timestamp webhook-signature: v3,64_character_hex_signature Content-Type: application/json
webhook-id: Unique identifier for the webhook eventwebhook-timestamp: The Unix timestamp when the event was generatedwebhook-signature:v3,<hex_signature>formatted HMAC signature for verifying the request
The signature is computed using: {webhook-id}.{webhook-timestamp}.{raw_body}
Constants used:
const (
WebhookSignatureVersion = "v3"
WebhookSignatureSeparator = ","
WebhookContentSeparator = "."
WebhookMaxAgeSeconds = 300 // 5 minutes
)How to Verify the Signature
Step 1: Extract Required Headers and Raw Body
From the incoming HTTP request:
- Get the
webhook-id - Get the
webhook-timestamp - Get the
webhook-signature - Capture the raw, unparsed request body
Step 2: Construct the Signature Payload
Join the fields using the . separator:
{webhook-id}.{webhook-timestamp}.{raw_body}
Step 3: Compute the HMAC SHA256 Signature
Use your webhook secret to compute the signature: HMAC_SHA256(key_without_prefix, “{webhook-id}.{webhook-timestamp}.{raw_body}”)
Important: Remove the whsec_ prefix from your secret before using it as the HMAC key. Do not base64 decode the remaining string.
Step 4: Compare Signatures Securely
The webhook-signature header is structured as:
v3,<hex_signature>
To extract the actual signature value, split by the comma (,) and use the second part. Then, use a constant-time comparison function to compare it against your computed HMAC.
Step 5: Validate Timestamp (recommended)
Reject requests where the timestamp is too old (older than WebhookMaxAgeSeconds, default: 5 minutes).
Demo Code
Python Example
import hmac
import hashlib
import os
import time
def verify_wavespeed_signature(payload: bytes, headers: dict, secret: str, max_age: int = 300) -> bool:
webhook_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signature_header = headers.get("webhook-signature")
if not (webhook_id and timestamp and signature_header):
raise Exception("Missing required headers")
parts = signature_header.split(',')
if len(parts) != 2 or parts[0] != 'v3':
raise Exception("Invalid signature header format")
received_signature = parts[1]
# payload must be raw request body bytes
signed_content = f"{webhook_id}.{timestamp}.{payload.decode()}".encode()
# Remove whsec_ prefix; do not base64 decode
key_without_prefix = secret[6:] if secret.startswith('whsec_') else secret
expected_signature = hmac.new(key_without_prefix.encode(), signed_content, hashlib.sha256).hexdigest()
# Validate timestamp
if abs(time.time() - int(timestamp)) > max_age:
raise Exception("Signature timestamp too old")
if not hmac.compare_digest(expected_signature, received_signature):
raise Exception("Invalid signature")
return True
# Local self-test. In your handler, pass the raw request body and received headers.
webhook_id = "pred_abc123"
payload = b'{"id":"pred_abc123","model":"wavespeed-ai/z-image/turbo","outputs":["https://example.com/output/example.jpeg"],"urls":{"get":"https://api.wavespeed.ai/api/v3/predictions/pred_abc123/result"},"status":"completed"}'
timestamp = str(int(time.time()))
secret = os.environ["WAVESPEED_WEBHOOK_SECRET"]
key = secret[6:] if secret.startswith("whsec_") else secret
signature = hmac.new(key.encode(), f"{webhook_id}.{timestamp}.".encode() + payload, hashlib.sha256).hexdigest()
headers = {
"webhook-id": webhook_id,
"webhook-timestamp": timestamp,
"webhook-signature": f"v3,{signature}",
}
print(verify_wavespeed_signature(payload, headers, secret))JavaScript (Node.js) Example
const crypto = require('crypto');
function verifyWaveSpeedAISignature(payload, headers, secret, maxAgeSeconds = 300) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatureHeader = headers['webhook-signature'];
if (!id || !timestamp || !signatureHeader) {
throw new Error('Missing required headers');
}
const parts = signatureHeader.split(',');
if (parts.length !== 2 || parts[0] !== 'v3' || !/^[a-f0-9]{64}$/i.test(parts[1])) {
throw new Error('Invalid signature header format');
}
const receivedSignature = parts[1];
// Use raw body string (do not re-serialize)
const signedContent = `${id}.${timestamp}.${payload}`;
// Remove whsec_ prefix; do not base64 decode
const keyWithoutPrefix = secret.startsWith('whsec_') ? secret.slice(6) : secret;
const expectedSignature = crypto
.createHmac('sha256', keyWithoutPrefix)
.update(signedContent)
.digest('hex');
const ageSeconds = Math.abs(Date.now() / 1000 - parseInt(timestamp, 10));
if (ageSeconds > maxAgeSeconds) {
throw new Error('Signature timestamp too old');
}
if (!crypto.timingSafeEqual(Buffer.from(expectedSignature, 'hex'), Buffer.from(receivedSignature, 'hex'))) {
throw new Error('Invalid signature');
}
return true;
}
// Local self-test. In your handler, pass the raw request body and received headers.
const id = 'pred_abc123';
const payload = '{"id":"pred_abc123","model":"wavespeed-ai/z-image/turbo","outputs":["https://example.com/output/example.jpeg"],"urls":{"get":"https://api.wavespeed.ai/api/v3/predictions/pred_abc123/result"},"status":"completed"}';
const timestamp = Math.floor(Date.now() / 1000).toString();
const secret = process.env.WAVESPEED_WEBHOOK_SECRET;
if (!secret) throw new Error('Set WAVESPEED_WEBHOOK_SECRET');
const key = secret.startsWith('whsec_') ? secret.slice(6) : secret;
const signature = crypto.createHmac('sha256', key).update(`${id}.${timestamp}.${payload}`).digest('hex');
const headers = {
'webhook-id': id,
'webhook-timestamp': timestamp,
'webhook-signature': `v3,${signature}`,
};
console.log(verifyWaveSpeedAISignature(payload, headers, secret));Go Example
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"strconv"
"strings"
"time"
)
// verifyWaveSpeedSignature verifies a WaveSpeedAI webhook signature
// Returns true if valid, error if invalid
func verifyWaveSpeedSignature(payload []byte, headers map[string]string, secret string) (bool, error) {
webhookID := headers["webhook-id"]
timestamp := headers["webhook-timestamp"]
signatureHeader := headers["webhook-signature"]
if webhookID == "" || timestamp == "" || signatureHeader == "" {
return false, fmt.Errorf("missing required headers")
}
parts := strings.Split(signatureHeader, ",")
if len(parts) != 2 || parts[0] != "v3" {
return false, fmt.Errorf("invalid signature header format")
}
receivedSignature := parts[1]
signedContent := fmt.Sprintf("%s.%s.%s", webhookID, timestamp, string(payload))
// Remove whsec_ prefix
keyWithoutPrefix := secret
if strings.HasPrefix(secret, "whsec_") {
keyWithoutPrefix = secret[6:]
}
expectedSignature := hmac.New(sha256.New, []byte(keyWithoutPrefix))
expectedSignature.Write([]byte(signedContent))
expectedSigHex := hex.EncodeToString(expectedSignature.Sum(nil))
// Timestamp validation
webhookTime, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false, fmt.Errorf("invalid timestamp: %v", err)
}
age := time.Now().Unix() - webhookTime
if age < 0 {
age = -age
}
if age > 300 {
return false, fmt.Errorf("signature timestamp too old")
}
// Constant-time comparison
if !hmac.Equal([]byte(expectedSigHex), []byte(receivedSignature)) {
return false, fmt.Errorf("invalid signature")
}
return true, nil
}
func main() {
// Local self-test. In your handler, pass the raw request body and received headers.
webhookID := "pred_abc123"
payload := []byte(`{"id":"pred_abc123","model":"wavespeed-ai/z-image/turbo","outputs":["https://example.com/output/example.jpeg"],"urls":{"get":"https://api.wavespeed.ai/api/v3/predictions/pred_abc123/result"},"status":"completed"}`)
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
secret := os.Getenv("WAVESPEED_WEBHOOK_SECRET")
if secret == "" {
panic("set WAVESPEED_WEBHOOK_SECRET")
}
key := strings.TrimPrefix(secret, "whsec_")
mac := hmac.New(sha256.New, []byte(key))
mac.Write([]byte(fmt.Sprintf("%s.%s.%s", webhookID, timestamp, payload)))
signature := hex.EncodeToString(mac.Sum(nil))
headers := map[string]string{
"webhook-id": webhookID,
"webhook-timestamp": timestamp,
"webhook-signature": "v3," + signature,
}
result, err := verifyWaveSpeedSignature(payload, headers, secret)
if err != nil {
fmt.Printf("Verification failed: %v\n", err)
return
}
fmt.Printf("Verification result: %t\n", result)
}
Raw HTTP Shell (for manual testing)
ID="pred_abc123"
TIMESTAMP=$(date +%s)
BODY='{"id":"pred_abc123","model":"wavespeed-ai/z-image/turbo","outputs":["https://example.com/output/example.jpeg"],"urls":{"get":"https://api.wavespeed.ai/api/v3/predictions/pred_abc123/result"},"status":"completed"}'
MESSAGE="$ID.$TIMESTAMP.$BODY"
: "${WAVESPEED_WEBHOOK_SECRET:?Set WAVESPEED_WEBHOOK_SECRET}"
SECRET="$WAVESPEED_WEBHOOK_SECRET"
# Remove whsec_ prefix (do not base64 decode)
KEY_WITHOUT_PREFIX="${SECRET#whsec_}"
# Compute hex signature
SIGNATURE=$(printf "%s" "$MESSAGE" | openssl dgst -sha256 -hmac "$KEY_WITHOUT_PREFIX" | sed 's/^.* //')
echo "\"webhook-id\"": "\"$ID\"",
echo "\"webhook-timestamp\"": \""$TIMESTAMP\"",
echo "\"webhook-signature\"": \""v3,$SIGNATURE\""
curl --fail-with-body --connect-timeout 10 --max-time 30 \
-X POST https://your-webhook-url.com \
-H "webhook-id: $ID" \
-H "webhook-timestamp: $TIMESTAMP" \
-H "webhook-signature: v3,$SIGNATURE" \
-H "Content-Type: application/json" \
-d "$BODY"Legacy Troubleshooting
- Signature verification fails: Check that all three components match exactly:
id,timestamp, andraw_body. Any character difference will cause failure (including whitespace, newlines, field order). - Timestamp expired: The
webhook-timestampused to generate the signature must match the request header and be within 5 minutes. - Key processing error: Must remove
whsec_prefix and use the remaining string directly as HMAC key (do not base64 decode). - Body contains base64 fields: This is fine. Use the raw request body bytes as-is for signature verification.
Summary
- Request webhooks with
standard_webhookand verify them with an official Standard Webhooks library - Get the secret on the API Keys page or from
GET /api/v3/webhook/secret - Use the raw, unparsed request body for signature verification and check the timestamp
- After rotating, the previous secret stays valid for 24 hours
Webhook verification is critical for security. Never trust webhook data without validation.
For any questions or issues, contact support@wavespeed.ai.