Signature Verification
Verify each delivery before acting on it.
Algorithm
- Read
X-PipAI-TimestampandX-PipAI-Signature. - Reject if the timestamp is more than 5 minutes from now (replay protection).
- Compute
HMAC-SHA256(webhook_secret, timestamp + "." + raw_body). - Compare hex-encoded digest to the signature header using a constant-time comparison.
Sample (Python)
import hmac, hashlib, time
def verify(secret, timestamp, body, signature):
if abs(time.time() * 1000 - int(timestamp)) > 5 * 60 * 1000:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.{body}".encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
Sample (Node.js)
const crypto = require('crypto');
function verifyWebhook(secret, timestamp, body, signature) {
if (Math.abs(Date.now() - parseInt(timestamp, 10)) > 5 * 60 * 1000) {
return false;
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${body}`)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}
Sample (Go)
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"time"
)
func VerifyWebhook(secret, timestamp, body, signature string) bool {
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if abs(time.Now().UnixMilli()-ts) > 5*60*1000 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "%s.%s", timestamp, body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
func abs(n int64) int64 { if n < 0 { return -n }; return n }
Common pitfalls
- Sign the raw body, not a parsed copy. Don't trust
Content-Typeand JSON-decode-then-re-encode before computing the HMAC. Even semantically-equivalent JSON (different key order, different whitespace, escaped vs. unescaped slashes) produces a different byte sequence and a different signature. Capture the raw request body bytes before any middleware touches them. - Don't strip or normalise whitespace. Sign the exact raw body as transmitted on the wire. Trimming trailing newlines, collapsing internal whitespace, or re-indenting will all break verification.
- Use a constant-time comparison. Use
hmac.compare_digest(Python),crypto.timingSafeEqual(Node.js), orhmac.Equal(Go). A naive==or string compare leaks timing information and can let an attacker recover a valid signature byte by byte. - Verify before processing — and reject with
400, not401. A401triggers PipAI's retry logic, which will resend the same (still-invalid) payload over and over. Return400 Bad Requestfor signature failures so the delivery is recorded as a permanent failure for that event and not retried.
Replay protection
The signature alone proves authenticity — only PipAI and your endpoint know the webhook secret. The 5-minute timestamp window adds a second guarantee: even if an attacker intercepts a valid signed request, they cannot replay it more than 5 minutes later, because your verifier will reject it as stale.
Combined with HTTPS (which prevents on-path interception in the first place) and the constant-time signature check, this gives strong end-to-end delivery integrity.
For belt-and-braces protection — especially given the at-least-once delivery guarantee — consumers should also keep a rolling cache of recently-seen event_id values and reject duplicates. A 24-hour TTL is more than enough; PipAI never retries beyond that horizon. This both deduplicates legitimate retries and shuts down any in-window replay attempts that slip past the timestamp check.