Verifying Webhook Signatures
Signature verification is a security requirement, not a recommendation. Every production webhook handler must verify signatures before acting on a payload.
Why verify signatures?
Your webhook URL is, by necessity, a public HTTP endpoint. Without signature verification, anyone who discovers that URL — by guessing, scanning, or finding it leaked in a log — can send a fake transaction.completed payload and trick your server into believing a payment happened that never did.
PayKore cryptographically signs every webhook payload using a secret only you and PayKore know. Verifying the signature proves two things: the payload genuinely came from PayKore, and it hasn't been altered in transit.
Never process a webhook payload without verifying its signature first. An unverified webhook handler is a direct path to fraud — a forged transaction.completed event could trigger you to ship goods, unlock features, or release funds for a payment that doesn't exist.
How signing works
PayKore uses HMAC-SHA256 to sign the raw request body, keyed with your webhook secret (the secret value returned when you registered the endpoint — see Webhooks Overview).
The signature is sent in the X-PayKore-Signature header, formatted as sha256={hex_signature}:
X-PayKore-Signature: sha256=5d41402abc4b2a76b9719d911017c592e3a3b8e1c4f6a2b9d8e7f1a0c3b5d9e
In pseudocode:
signature = "sha256=" + HMAC_SHA256(webhookSecret, rawRequestBody)
To verify, you compute the same HMAC over the body you received, using your stored secret, and compare it against the header value.
Verification code examples
Use the raw bytes of the request body in all of these examples — never a parsed-and-reserialized JSON object. JSON.parse() followed by JSON.stringify() (or your language's equivalent) can reorder keys or alter whitespace, which changes the byte sequence and makes the signature comparison fail even for a legitimate payload. Capture the raw body before any JSON parsing middleware touches it.
Node.js / TypeScript:
import crypto from 'crypto';
function verifySignature(secret: string, rawBody: Buffer | string, signature: string): boolean {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
Python:
import hmac, hashlib
def verify_signature(secret: str, raw_body: bytes, signature: str) -> bool:
expected = 'sha256=' + hmac.new(
secret.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
Go:
func VerifySignature(secret, rawBody []byte, signature string) bool {
mac := hmac.New(sha256.New, secret)
mac.Write(rawBody)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
All three use a constant-time comparison (crypto.timingSafeEqual, hmac.compare_digest, hmac.Equal) rather than === or ==. This matters — see the next section.
Common mistakes
- Parsing JSON before verifying. If your framework's body-parser middleware runs before your verification logic, you may only have access to the parsed object, not the original bytes. Configure your route to capture the raw body (e.g.
express.raw({ type: 'application/json' })in Express) ahead of any JSON-parsing middleware. - Using string comparison instead of constant-time comparison. A naive
expected === signaturecheck leaks timing information about how many characters matched before the first mismatch, which can theoretically be exploited to guess a valid signature byte-by-byte. Always use your language's constant-time comparison function. - Not checking the
sha256=prefix. Some implementations strip the header value and compare only the hex digest, forgetting the prefix is part of the expected string. Make sure your "expected" value includessha256=exactly as PayKore sends it, or strip it consistently on both sides. - Using your API key instead of your webhook secret. These are two different credentials. Your API key (
sk_test_…/sk_live_…) authenticates outbound requests you make to PayKore. Your webhook secret (whsec_…) is used only to verify inbound webhook payloads. Mixing them up will make every verification fail.
Finding your webhook secret
Each webhook endpoint you register has its own unique secret, shown once at creation time (see Webhooks Overview). If you didn't save it, you can view it again in the dashboard:
Webhooks → select your endpoint → "Show Secret"
You can rotate the secret at any time from the same screen. After rotation, the old secret remains valid for 5 minutes, so in-flight retries signed with the previous secret still verify successfully during the transition. Update your stored secret promptly after rotating to avoid a gap once that window closes.
Next steps
- Webhooks Overview → — Delivery, retries, and idempotent event handling.
- Event Reference → — Full catalog of every webhook event and payload.