Verifying webhooks manually
Each webhook call includes three headers with additional information used for verification:
svix-id: The unique message identifier for the webhook message. This identifier is unique across all messages but will remain the same when the same webhook is being resent (e.g., due to a previous failure).
svix-timestamp: The timestamp in seconds since the epoch.
svix-signature: The Base64-encoded list of signatures, space-delimited.
USE THE RAW REQUEST BODY
You need to use the raw request body when verifying webhooks, as the cryptographic signature is sensitive to even the slightest changes. You should watch out for frameworks that parse the request as JSON and then stringify it because this too will break the signature verification.
Constructing the Signed Content
The content to sign is created by concatenating the ID, timestamp, and payload, separated by a period (.). In code, it would look something like this:
const signedContent = `${svix_id}.${svix_timestamp}.${body}`;Where body is the raw body of the request. The signature is highly sensitive to any changes, so even a minor modification in the body will result in a completely different signature. Therefore, you should not alter the body in any way before verifying it.
Determining the Expected Signature
Txn uses HMAC with SHA-256 to sign its webhooks.
To calculate the expected signature, you should HMAC the signedContent (constructed as described above) using the Base64-decoded portion of your signing secret (the part after the whsec_ prefix) as the key. For example, if your secret is whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw, you should use MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw as the key.
Here's an example of hot to calcialte the signature in Ruby on Rails:
Base64.encode64(OpenSSL::HMAC.digest(OpenSSL::Digest.new("sha256"), Base64.decode64(secret), "#{msgId}.#{timestamp}.#{payload}")).stripHere’s an example of how to calculate the signature in Node.js:
const crypto = require('crypto');
const signedContent = `${svix_id}.${svix_timestamp}.${body}`;
const secret = "whsec_5WbX5kEWLlfzsGNjH64I8lOOqUB6e8FH";
// Base64 decode the secret key
const secretBytes = Buffer.from(secret.split('_')[1], "base64");
const signature = crypto
.createHmac('sha256', secretBytes)
.update(signedContent)
.digest('base64');
console.log(signature);
The generated signature should match one of the signatures sent in the svix-signature header.
The svix-signature header contains a list of space-delimited signatures with their corresponding version identifiers. The list usually contains one signature, but there can be multiple. For example:
v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= v1,bm9ldHUjKzFob2VudXRob2VodWUzMjRvdWVvdW9ldQo= v2,MzJsNDk4MzI0K2VvdSMjMTEjQEBAQDEyMzMzMzEyMwo=Before verifying the signature, make sure to remove the version prefix and delimiter (e.g., v1,).
Security Note: Use a constant-time string comparison method to compare signatures and prevent timing attacks.
Verify Timestamp
As mentioned above, Txn includes the timestamp of the attempt in the svix-timestamp header. Compare this timestamp against your system's timestamp to ensure it falls within your acceptable tolerance range, helping to prevent timestamp attacks.
Last updated