Webhook Signature Verification
Every webhook Von Payments delivers is signed with HMAC-SHA256, keyed with the per-endpoint whsec_* secret — returned once by the dashboard or by POST /v1/webhook_subscriptions (creating, rotating and revoking one). Verify the signature before you process anything.
Header format (v1)
x-vonpay-signature: t=1714406400,v1=abc123def456...
t— unix timestamp (seconds, integer) of when the signature was generatedv1— lowercase hex-encoded HMAC-SHA256 over the signed payload
There is exactly one v1= entry, and no rotation grace window on our side. Rotating mints a new secret; within moments every delivery is signed with only that one, and a delivery you reject with a 4xx is not retried. During your switch-over run the verifier once per secret (each takes one) and accept if either passes, then drop the old one as soon as the new one verifies — sequence on Rotating a signing secret. Outside a rotation, compare against your current secret only.
Signed payload
The HMAC input is the concatenation of the timestamp, a literal period, and the raw request body:
signed_payload = t + "." + raw_body
t— the exact same value from the headerraw_body— the HTTP request body as received, byte-for-byte, before any parsing or whitespace normalization
HMAC the raw bytes, not the parsed JSON. If you re-serialize the JSON object before HMAC'ing, the signature will not match — JSON serializers normalize whitespace and key order differently across languages.
The algorithm
v1 = lowercase_hex(HMAC_SHA256(key=signing_secret, message=signed_payload))
- Algorithm: HMAC-SHA256
- Key: the raw signing secret string as UTF-8 bytes, including the
whsec_prefix. Do not base64-decode and do not strip the prefix — pass the secret verbatim to your HMAC library's key parameter. - Encoding: lowercase hex
Verification steps
A conforming verifier must:
- Parse the header. Extract
tand everyv1=…value. One is sent today; the format allows up to two, and more than two is malformed — reject with 401. - Reject stale timestamps. The replay window is asymmetric — reject if
now - t > 300(more than 5 minutes old) ORt - now > 30(more than 30 seconds in the future). A future timestamp should never happen under normal flow; 30 seconds only covers minor receiver-clock skew. - Recompute the HMAC. Form
signed_payload = t + "." + raw_body. Computeexpected = lowercase_hex(HMAC_SHA256(signing_secret, signed_payload)). - Constant-time compare, with no length-based early exit. For each
v1from the header, constant-time compare againstexpected; a length mismatch goes through the same path (wrap the timing-safe compare in try/catch and treat a throw as no-match). Accept if any compare returns true, otherwise reject with 401. Never==/===; use a constant-time helper:
- Node:
crypto.timingSafeEqual(requires equal-length buffers — wrap in try/catch) - Python:
hmac.compare_digest(constant-time regardless of length) - Go:
subtle.ConstantTimeCompare - Ruby:
Rack::Utils.secure_compare
Replay window (asymmetric)
Past 5 minutes, future 30 seconds. Every retry is re-signed at delivery time, so a legitimate delivery is always fresh; the future allowance covers receiver clock skew only.
Idempotency
Events carry an id on the envelope (e.g. vp_evt_live_V1StGXR8Z5jdHi6B). The same id is redelivered after your 5xx or a manual resend — and a retry after a rotation is re-signed with the new secret under the same id — so guard on id, not on the signature, and return 200 for an id you have already processed.
Code examples
Node
const crypto = require('crypto');
function verifyVonPaySignature(rawBody, headerValue, secret) {
if (!headerValue) return false;
const parts = headerValue.split(',').map((p) => p.trim());
const tPart = parts.find((p) => p.startsWith('t='));
if (!tPart) return false;
const t = parseInt(tPart.slice(2), 10);
if (!Number.isFinite(t)) return false;
const now = Math.floor(Date.now() / 1000);
if (now - t > 300) return false; // > 5 min old
if (t - now > 30) return false; // > 30 sec in future
const v1Parts = parts.filter((p) => p.startsWith('v1='));
if (v1Parts.length === 0 || v1Parts.length > 2) return false;
const signed = `${t}.${rawBody}`;
const expected = crypto.createHmac('sha256', secret).update(signed).digest('hex');
const expectedBuf = Buffer.from(expected, 'utf8');
for (const part of v1Parts) {
const candidateBuf = Buffer.from(part.slice(3), 'utf8');
try {
// timingSafeEqual requires equal lengths. A length mismatch throws and is
// treated as no-match. No length short-circuit — all comparisons go
// through a constant-time path.
if (crypto.timingSafeEqual(candidateBuf, expectedBuf)) return true;
} catch {
// length mismatch — continue to next v1
}
}
return false;
}
Python
import hashlib
import hmac
import time
def verify_vonpay_signature(raw_body: bytes, header_value: str, secret: str) -> bool:
if not header_value:
return False
parts = [p.strip() for p in header_value.split(",")]
t_part = next((p for p in parts if p.startswith("t=")), None)
if not t_part:
return False
try:
t = int(t_part[2:])
except ValueError:
return False
now = int(time.time())
if now - t > 300: # > 5 min old
return False
if t - now > 30: # > 30 sec in future
return False
v1_parts = [p for p in parts if p.startswith("v1=")]
if not v1_parts or len(v1_parts) > 2:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
for part in v1_parts:
# hmac.compare_digest is constant-time regardless of length
if hmac.compare_digest(part[3:], expected):
return True
return False
Ruby
require "openssl"
require "rack/utils"
def verify_vonpay_signature(raw_body, header_value, secret)
return false if header_value.nil? || header_value.empty?
parts = header_value.split(",").map(&:strip)
t_part = parts.find { |p| p.start_with?("t=") }
return false unless t_part
t = Integer(t_part[2..]) rescue (return false)
now = Time.now.to_i
return false if now - t > 300 # > 5 min old
return false if t - now > 30 # > 30 sec in future
v1_parts = parts.select { |p| p.start_with?("v1=") }
return false if v1_parts.empty? || v1_parts.size > 2
signed = "#{t}.#{raw_body}"
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, signed)
v1_parts.each do |part|
return true if Rack::Utils.secure_compare(expected, part[3..])
end
false
end
PHP
function verify_vonpay_signature(string $raw_body, string $header_value, string $secret): bool {
if ($header_value === "") return false;
$parts = array_map("trim", explode(",", $header_value));
$t_part = null;
foreach ($parts as $p) {
if (str_starts_with($p, "t=")) { $t_part = $p; break; }
}
if ($t_part === null) return false;
if (!ctype_digit(substr($t_part, 2))) return false;
$t = (int)substr($t_part, 2);
$now = time();
if ($now - $t > 300) return false; // > 5 min old
if ($t - $now > 30) return false; // > 30 sec in future
$v1_parts = array_values(array_filter($parts, fn($p) => str_starts_with($p, "v1=")));
if (count($v1_parts) === 0 || count($v1_parts) > 2) return false;
$signed = $t . "." . $raw_body;
$expected = hash_hmac("sha256", $signed, $secret);
foreach ($v1_parts as $p) {
if (hash_equals($expected, substr($p, 3))) return true;
}
return false;
}
Go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
func VerifyVonPaySignature(rawBody []byte, headerValue, secret string) bool {
if headerValue == "" {
return false
}
parts := strings.Split(headerValue, ",")
for i, p := range parts {
parts[i] = strings.TrimSpace(p)
}
var t int64
var tFound bool
for _, p := range parts {
if strings.HasPrefix(p, "t=") {
v, err := strconv.ParseInt(p[2:], 10, 64)
if err != nil {
return false
}
t = v
tFound = true
break
}
}
if !tFound {
return false
}
now := time.Now().Unix()
if now-t > 300 {
return false
}
if t-now > 30 {
return false
}
var v1Parts []string
for _, p := range parts {
if strings.HasPrefix(p, "v1=") {
v1Parts = append(v1Parts, p[3:])
}
}
if len(v1Parts) == 0 || len(v1Parts) > 2 {
return false
}
signed := strconv.FormatInt(t, 10) + "." + string(rawBody)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signed))
expected := hex.EncodeToString(mac.Sum(nil))
for _, v := range v1Parts {
if hmac.Equal([]byte(expected), []byte(v)) {
return true
}
}
return false
}
Shell (curl / openssl) — sanity check only
For ad-hoc verification — confirming a captured payload against its signature from a terminal. Not for a production handler: shell = is variable-time and the replay window is a manual date compare. Use one of the verifiers above for the real receiver.
Given three captured values — the raw request body, the full x-vonpay-signature header, and your whsec_* secret — verify like this:
RAW_BODY='{"id":"vp_evt_live_V1StGXR8Z5jdHi6B","type":"charge.succeeded","created":1728936000,"livemode":true,"merchant_id":"b6b8d25f-80d5-4b31-8ac6-fd3c5727c4ce","data":{"amount":1499}}'
HEADER='t=1728936000,v1=abc123def456...'
SECRET='whsec_REPLACE_WITH_YOUR_ENDPOINT_SECRET'
T=$(printf '%s' "$HEADER" | tr ',' '\n' | grep '^t=' | head -1 | cut -d= -f2)
V1=$(printf '%s' "$HEADER" | tr ',' '\n' | grep '^v1=' | head -1 | cut -d= -f2)
[ -z "$T" ] && { echo "could not parse t= from header"; exit 1; }
[ -z "$V1" ] && { echo "could not parse v1= from header"; exit 1; }
SIGNED="${T}.${RAW_BODY}"
EXPECTED=$(printf '%s' "$SIGNED" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')
[ "$V1" = "$EXPECTED" ] && echo "signature OK" || echo "signature MISMATCH"
# Optional: enforce the replay window (T was validated above)
NOW=$(date +%s)
if [ "$((NOW - T))" -gt 300 ] || [ "$((T - NOW))" -gt 30 ]; then
echo "timestamp outside replay window"
fi
Notes:
printf '%s'(notecho) —echoadds a trailing newline that breaks the HMAC input.awk '{print $NF}'takes the last whitespace-delimited field, which is always the hex digest regardless of how openssl formats the prefix label ((stdin)=,HMAC-SHA2-256(stdin)=, or just=on older LibreSSL).- The body must be the raw bytes as received, byte-for-byte. If you captured the payload via
jqor any tool that re-serializes, the HMAC will not match. - Bodies containing a single quote break the
RAW_BODY='...'assignment. Use a here-doc instead —RAW_BODY=$(cat <<'ENDBODY'…ENDBODY)— or write the body to a temp file andprintf '%s' "$T." > tmp.signed && cat tmp.body >> tmp.signed && openssl dgst -sha256 -hmac "$SECRET" < tmp.signed | awk '{print $NF}'. - The secret sits in process args and shell history for the duration of the command — run it where other local users cannot observe
ps, and keep theSECRET=line out of history.
To capture a real event for replay-verification: open /dashboard/developers/events, open the event, and copy the payload and the attempt's response details shown there.
Rejection response codes
| Condition | Response |
|---|---|
| Header missing | 401 |
Header malformed (no t=, no v1=, non-integer t) | 401 |
More than 2 v1= entries | 401 (malformed) |
now - t > 300 (stale) OR t - now > 30 (future-skew) | 401 |
No v1 HMAC matches | 401 |
Duplicate id already processed | 200 (idempotent no-op) |
Common mistakes
| Mistake | Fix |
|---|---|
Using == to compare signatures | Use a constant-time compare |
| Length-based early return before the compare | Always go through the constant-time path (wrap in try/catch) |
| HMAC'ing the parsed JSON object | HMAC the raw request body bytes |
Accepting stale t | Enforce asymmetric window (past 5 min, future 30 sec) |
Accepting >2 v1= entries | Reject as malformed |
Base64-decoding the whsec_ secret | Use the raw string as UTF-8 bytes |
Porting a verifier from another provider
The header shape (t=…,v1=…, HMAC-SHA256 over t.payload) resembles other widely-used schemes, with four differences that bite a verifier copied verbatim:
- Replay window: we reject past > 5 min and future > 30 sec. Many schemes reject past only, with no future tolerance.
- Multi-
v1=cap: we reject headers with more than 2v1=entries. - Header name:
x-vonpay-signature(lowercase, hyphenated). - Key encoding: use the raw
whsec_…string as UTF-8 bytes.
Related
- Webhooks — overview: registering endpoints, retry behavior, code examples
- Webhook Event Reference — event catalog and payload schemas
- Webhook Signing Secrets — creating, rotating, and revoking endpoint secrets