Plaidly Docs

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.

On this page