Docs/Webhooks/Security

Webhook Security

Secure your webhook endpoints with signature verification and best practices to prevent spoofing attacks.

Why Verify Signatures?

Without verification, attackers could send fake webhook events to your endpoint. They might:

  • Mark orders as paid without actual payment
  • Trigger unintended actions in your system
  • Flood your endpoint with fake events

Signature Header

Every webhook includes an x-web3pay-signature header:

x-web3pay-signature: t=1732624500,v1=abc123def456...

The header contains two parts:

  • t - Unix timestamp when the webhook was sent
  • v1 - HMAC-SHA256 signature of the payload

Verification Process

  1. Extract the timestamp and signature from the header
  2. Validate the timestamp is recent (within 5 minutes)
  3. Compute the expected signature using your webhook secret
  4. Compare signatures using constant-time comparison

Implementation Examples

Node.js

javascript
1400">const crypto = require(400">'crypto');
2 
3400">const WEBHOOK_SECRET = process.env.WEB3PAY_WEBHOOK_SECRET;
4400">const TOLERANCE_SECONDS = 300; 500">// 5 minutes
5 
6400">function verifyWebhookSignature(payload, signatureHeader) {
7 400">if (!signatureHeader) {
8 400">throw 400">new Error(400">'Missing signature header');
9 }
10 
11 500">// Parse the signature header
12 400">const parts = {};
13 signatureHeader.split(400">',').forEach(part => {
14 400">const [key, value] = part.split(400">'=');
15 parts[key] = value;
16 });
17 
18 400">const timestamp = parseInt(parts.t, 10);
19 400">const signature = parts.v1;
20 
21 400">if (!timestamp || !signature) {
22 400">throw 400">new Error(400">'Invalid signature format');
23 }
24 
25 500">// Verify timestamp is recent
26 400">const now = Math.floor(Date.now() / 1000);
27 400">if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) {
28 400">throw 400">new Error(400">'Timestamp outside tolerance window');
29 }
30 
31 500">// Compute expected signature
32 400">const signedPayload = 400">`${timestamp}.${payload}`;
33 400">const expectedSignature = crypto
34 .createHmac(400">'sha256', WEBHOOK_SECRET)
35 .update(signedPayload, 400">'utf8')
36 .digest(400">'hex');
37 
38 500">// Constant-time comparison
39 400">const signatureBuffer = Buffer.400">from(signature, 400">'utf8');
40 400">const expectedBuffer = Buffer.400">from(expectedSignature, 400">'utf8');
41 
42 400">if (signatureBuffer.length !== expectedBuffer.length) {
43 400">throw 400">new Error(400">'Invalid signature');
44 }
45 
46 400">if (!crypto.timingSafeEqual(signatureBuffer, expectedBuffer)) {
47 400">throw 400">new Error(400">'Invalid signature');
48 }
49 
50 400">return 400">true;
51}

Python

python
1400">import hmac
2400">import hashlib
3400">import time
4400">import os
5 
6WEBHOOK_SECRET = os.environ[400">&500">#039;WEB3PAY_WEBHOOK_SECRET']
7TOLERANCE_SECONDS = 300 500"># 5 minutes
8 
9400">def verify_webhook_signature(payload: str, signature_header: str) -> bool:
10 400">if not signature_header:
11 400">raise ValueError(400">&500">#039;Missing signature header')
12 
13 500"># Parse signature header
14 parts = dict(p.split(400">&500">#039;=') 400">for p 400">in signature_header.split(400">','))
15 timestamp = int(parts.get(400">&500">#039;t', 0))
16 signature = parts.get(400">&500">#039;v1', '400">')
17 
18 400">if not timestamp or not signature:
19 400">raise ValueError(&500">#039;Invalid signature format')
20 
21 500"># Check timestamp
22 now = int(time.time())
23 400">if abs(now - timestamp) > TOLERANCE_SECONDS:
24 400">raise ValueError(400">&500">#039;Timestamp outside tolerance window')
25 
26 500"># Compute expected signature
27 signed_payload = f400">"{timestamp}.{payload}"
28 expected = hmac.new(
29 WEBHOOK_SECRET.encode(400">&500">#039;utf-8'),
30 signed_payload.encode(400">&500">#039;utf-8'),
31 hashlib.sha256
32 ).hexdigest()
33 
34 500"># Constant-time comparison
35 400">if not hmac.compare_digest(signature, expected):
36 400">raise ValueError(400">&500">#039;Invalid signature')
37 
38 400">return 400">True

Go

go
1package webhook
2 
3import (
4 "crypto/hmac"
5 "crypto/sha256"
6 "encoding/hex"
7 "errors"
8 "math"
9 "strconv"
10 "strings"
11 "time"
12)
13 
14const ToleranceSeconds = 300
15 
16func VerifySignature(payload, signatureHeader, secret string) error {
17 if signatureHeader == "" {
18 return errors.New("missing signature header")
19 }
20 
21 // Parse header
22 var timestamp int64
23 var signature string
24 for _, part := range strings.Split(signatureHeader, ",") {
25 kv := strings.SplitN(part, "=", 2)
26 if len(kv) != 2 {
27 continue
28 }
29 switch kv[0] {
30 case "t":
31 timestamp, _ = strconv.ParseInt(kv[1], 10, 64)
32 case "v1":
33 signature = kv[1]
34 }
35 }
36 
37 if timestamp == 0 || signature == "" {
38 return errors.New("invalid signature format")
39 }
40 
41 // Check timestamp
42 now := time.Now().Unix()
43 if math.Abs(float64(now-timestamp)) > ToleranceSeconds {
44 return errors.New("timestamp outside tolerance")
45 }
46 
47 // Compute expected signature
48 signedPayload := strconv.FormatInt(timestamp, 10) + "." + payload
49 mac := hmac.New(sha256.New, []byte(secret))
50 mac.Write([]byte(signedPayload))
51 expected := hex.EncodeToString(mac.Sum(nil))
52 
53 // Constant-time comparison
54 if !hmac.Equal([]byte(signature), []byte(expected)) {
55 return errors.New("invalid signature")
56 }
57 
58 return nil
59}

Using the SDK

Our SDK includes a helper function for verification:

javascript
400">import { verifyWebhookSignature } 400">from 400">'@web3pay/sdk';

app.post(400">'/webhooks', express.raw({ 400">type: 400">'application/json' }), (req, res) => {
  400">const signature = req.headers[400">'x-web3pay-signature'];
  400">const payload = req.body.toString();

  400">try {
    verifyWebhookSignature(payload, signature, process.env.WEBHOOK_SECRET);

    400">const event = JSON.parse(payload);
    500">// Process event...

    res.json({ received: 400">true });
  } 400">catch (err) {
    console.error(400">'Webhook verification failed:', err.message);
    res.status(400).send(400">'Invalid signature');
  }
});

Common Mistakes

Parsing body before verification

Don't parse the JSON before verifying. Use the raw request body for signature computation.

javascript
500">// WRONG - body is parsed
app.post(400">'/webhooks', express.json(), (req, res) => {
  500">// req.body is already parsed, can400">'t verify signature
});

500">// CORRECT - use raw body
app.post('/webhooks400">', express.raw({ 400">type: 'application/json' }), (req, res) => {
  400">const rawBody = req.body.toString(); 500">// Use this for verification
  500">// Then parse: JSON.parse(rawBody)
});

Not using constant-time comparison

Regular string comparison (===) leaks timing information. Always use constant-time comparison functions.

Ignoring the timestamp

Without timestamp validation, attackers could replay old valid webhooks. Always check the timestamp is within tolerance.

Rotating Webhook Secrets

To rotate your webhook secret without downtime:

  1. Generate a new secret in the dashboard
  2. Update your code to accept both old and new secrets
  3. Deploy your updated code
  4. Delete the old secret in the dashboard
  5. Remove the old secret from your code
javascript
400">const WEBHOOK_SECRETS = [
  process.env.WEBHOOK_SECRET_NEW,
  process.env.WEBHOOK_SECRET_OLD
].filter(Boolean);

400">function verifyWithAnySecret(payload, signature) {
  for (400">const secret of WEBHOOK_SECRETS) {
    400">try {
      verifyWebhookSignature(payload, signature, secret);
      400">return 400">true;
    } 400">catch {
      500">// Try next secret
    }
  }
  400">throw 400">new Error(400">'Invalid signature');
}
Back to home
Was this page helpful?