HMAC Signature Verification
Verify webhook authenticity with HMAC-SHA256
HMAC Signature Verification
Every webhook Plaidly sends includes an X-Plaidly-Signature header. Verify this signature before processing the payload to ensure the request genuinely came from Plaidly and hasn't been replayed.
Algorithm
The header value has the form t=<unix_seconds>,v1=<hex> (comma-separated key=value pairs; a rotating secret can add a second v1= value — see below). v1 is computed as:
signed_payload = "<t>." + raw_request_body
v1 = hex(HMAC-SHA256(webhook_secret, signed_payload))This is not a bare HMAC-SHA256(secret, raw_body) and the header is not prefixed with sha256= — both the timestamp and a . separator are part of the signed payload, and the prefix is t=...,v1=.... Verification must also reject timestamps older than 5 minutes to prevent replay.
Getting your webhook secret
webhook_secret is returned exactly once, in the response body of merchant registration (POST /v1/merchants). There is no dashboard to look it up afterward — if you lose it, use Rotate credentials to issue a new one.
During a secret rotation
If you've called Rotate credentials for webhook_secret, deliveries during the overlap window carry two v1= values (one per active secret), comma-separated in the same header, e.g. t=1718310000,v1=<hex_new>,v1=<hex_old>. Accept the event if either verifies.
Verification examples
Node.js
import crypto from 'node:crypto';
function verifyPlaidlyWebhook(
rawBody: string | Buffer,
signatureHeader: string,
secret: string,
toleranceSeconds = 300,
): boolean {
const parts = Object.fromEntries(
signatureHeader.split(',').map((part) => {
const [key, ...rest] = part.trim().split('=');
return [key, rest.join('=')];
}),
);
const ts = Number(parts.t);
if (!ts || Math.abs(Math.floor(Date.now() / 1000) - ts) > toleranceSeconds) {
return false;
}
const signedPayload = `${parts.t}.${rawBody}`;
const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');
const candidates = signatureHeader
.split(',')
.filter((p) => p.trim().startsWith('v1='))
.map((p) => p.trim().slice(3));
return candidates.some((candidate) => {
try {
return crypto.timingSafeEqual(Buffer.from(candidate, 'hex'), Buffer.from(expected, 'hex'));
} catch {
return false;
}
});
}Python
import hmac
import hashlib
import time
def verify_plaidly_webhook(raw_body: bytes, signature_header: str, secret: str, tolerance_seconds: int = 300) -> bool:
parts: dict[str, list[str]] = {}
for chunk in signature_header.split(","):
key, _, value = chunk.strip().partition("=")
parts.setdefault(key, []).append(value)
ts_values = parts.get("t")
if not ts_values:
return False
ts = int(ts_values[0])
if abs(int(time.time()) - ts) > tolerance_seconds:
return False
signed_payload = f"{ts}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, candidate) for candidate in parts.get("v1", []))Go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
)
func VerifyPlaidlyWebhook(rawBody []byte, signatureHeader, secret string, tolerance time.Duration) bool {
var ts string
var candidates []string
for _, part := range strings.Split(signatureHeader, ",") {
key, value, ok := strings.Cut(strings.TrimSpace(part), "=")
if !ok {
continue
}
switch key {
case "t":
ts = value
case "v1":
candidates = append(candidates, value)
}
}
tsInt, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return false
}
age := time.Since(time.Unix(tsInt, 0))
if age < 0 {
age = -age
}
if age > tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(fmt.Sprintf("%s.%s", ts, rawBody)))
expected := hex.EncodeToString(mac.Sum(nil))
for _, candidate := range candidates {
if hmac.Equal([]byte(candidate), []byte(expected)) {
return true
}
}
return false
}Important notes
- Always use timing-safe comparison (e.g.
crypto.timingSafeEqual,hmac.compare_digest,hmac.Equal) to prevent timing attacks - Read the raw body bytes before any JSON parsing — parsers may alter whitespace, and the signature covers the exact bytes Plaidly sent
- Reject signatures older than the 5-minute tolerance window
- If the signature does not match, return a non-2xx status and do not process the payload
See Webhooks for the event catalog and retry schedule, and Verify a webhook event for a runnable end-to-end recipe.