Overview
Every webhook sent by StableStack is signed with HMAC-SHA256 using your endpoint’s unique signing secret. Verifying signatures ensures that:
The request originated from StableStack
The payload has not been tampered with in transit
The request is not a replay of an older event
The signature field is included in every webhook payload:
"signature": "t=1778538982206,s=a43187f66dff73d9681a4ef43c5c93349d95fc10b6dc0482ec65e39b59564a83"
It contains two comma-separated components:
Component Description tUnix timestamp in milliseconds when the event was created sHMAC-SHA256 hex digest of the signed message
How Signatures Are Generated
StableStack generates signatures as follows:
Build the payload object (without the signature field):
{
"id" : "evt_..." ,
"timestamp" : 1778538982206 ,
"event_type" : "wallet.transaction.inbound" ,
"data" : { ... }
}
Serialize it to a JSON string (payloadString = JSON.stringify(payload))
Build the signed message:
message = "${timestamp}.${payloadString}"
Compute HMAC-SHA256(message, signingSecret) and hex-encode the result
Attach to the payload as:
"signature": "t=${timestamp},s=${hexDigest}"
Verifying Signatures
Node.js
const crypto = require ( 'crypto' );
function verifyWebhookSignature ( payload , signingSecret ) {
const { signature , ... payloadWithoutSignature } = payload ;
// 1. Parse the signature field
const parts = signature . split ( ',' );
const timestamp = parts . find ( p => p . startsWith ( 't=' )). slice ( 2 );
const receivedSig = parts . find ( p => p . startsWith ( 's=' )). slice ( 2 );
// 2. Reject stale events (replay protection)
const now = Date . now ();
const eventTime = parseInt ( timestamp , 10 );
const FIVE_MINUTES = 5 * 60 * 1000 ;
if ( Math . abs ( now - eventTime ) > FIVE_MINUTES ) {
throw new Error ( 'Webhook timestamp too old — possible replay attack' );
}
// 3. Rebuild the signed message
const payloadString = JSON . stringify ( payloadWithoutSignature );
const message = ` ${ timestamp } . ${ payloadString } ` ;
// 4. Compute expected signature
const expectedSig = crypto
. createHmac ( 'sha256' , signingSecret )
. update ( message )
. digest ( 'hex' );
// 5. Compare using constant-time comparison
const expectedBuf = Buffer . from ( expectedSig , 'hex' );
const receivedBuf = Buffer . from ( receivedSig , 'hex' );
if (
expectedBuf . length !== receivedBuf . length ||
! crypto . timingSafeEqual ( expectedBuf , receivedBuf )
) {
throw new Error ( 'Invalid webhook signature' );
}
return true ;
}
Express.js example:
app . post ( '/webhook' , express . json (), ( req , res ) => {
try {
verifyWebhookSignature ( req . body , process . env . SIGNING_SECRET );
} catch ( err ) {
return res . status ( 401 ). json ({ error: err . message });
}
const { id , event_type , data } = req . body ;
// Respond immediately, process asynchronously
res . status ( 200 ). json ({ received: true });
processEvent ( id , event_type , data ). catch ( console . error );
});
Python
import hmac
import hashlib
import json
import time
def verify_webhook_signature ( payload : dict , signing_secret : str ) -> bool :
signature = payload.get( "signature" , "" )
payload_without_sig = {k: v for k, v in payload.items() if k != "signature" }
# Parse t= and s= from signature string
parts = dict (part.split( "=" , 1 ) for part in signature.split( "," ))
timestamp = parts.get( "t" , "" )
received_sig = parts.get( "s" , "" )
# Replay protection: reject if older than 5 minutes
now_ms = int (time.time() * 1000 )
event_time = int (timestamp)
if abs (now_ms - event_time) > 5 * 60 * 1000 :
raise ValueError ( "Webhook timestamp too old — possible replay attack" )
# Rebuild signed message
payload_string = json.dumps(payload_without_sig, separators = ( "," , ":" ))
message = f " { timestamp } . { payload_string } "
# Compute and compare
expected_sig = hmac.new(
signing_secret.encode(),
message.encode(),
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected_sig, received_sig):
raise ValueError ( "Invalid webhook signature" )
return True
Replay Attack Prevention
The t timestamp in the signature allows you to reject events that are replayed after a delay. StableStack recommends rejecting any event older than 5 minutes .
const FIVE_MINUTES = 5 * 60 * 1000 ;
const eventTime = parseInt ( timestamp , 10 );
if ( Math . abs ( Date . now () - eventTime ) > FIVE_MINUTES ) {
return res . status ( 401 ). json ({ error: 'Stale webhook — rejected' });
}
If your server’s clock is significantly out of sync, legitimate events may be rejected. Ensure your server uses NTP time synchronisation.
Best Practices
Store your signing secret securely
Never hardcode your signing_secret in source code or expose it client-side. Store it as an environment variable and rotate it immediately if compromised. SIGNING_SECRET = your_secret_here
Always use constant-time comparison
Use crypto.timingSafeEqual (Node.js) or hmac.compare_digest (Python) when comparing signatures. Standard string equality (===) is vulnerable to timing attacks.
Respond before processing
Return a 200 response as soon as signature verification passes. Process the event asynchronously to avoid timeouts that could trigger unnecessary retries. res . status ( 200 ). json ({ received: true });
processEvent ( event ). catch ( console . error );
Track signature failures to detect misconfiguration or attack attempts: } catch ( err ) {
logger . warn ( 'Webhook signature verification failed' , {
error: err . message ,
event_id: req . body ?. id ,
});
return res . status ( 401 ). json ({ error: 'Unauthorized' });
}
Testing Signature Verification
You can test your verification logic using the example payload and the signing secret from your dashboard:
const testPayload = {
id: "evt_a0b8f4cc-95c4-4c74-9b18-050813546eb5" ,
timestamp: 1778538982206 ,
event_type: "wallet.transaction.inbound" ,
signature: "t=1778538982206,s=a43187f66dff73d9681a4ef43c5c93349d95fc10b6dc0482ec65e39b59564a83" ,
data: {
id: "dd1aebfd-acec-4367-a8dd-bdecea396753" ,
amount: "20.00000000" ,
status: "COMPLETED" ,
// ... rest of the payload
}
};
// Should return true with the correct signing secret
verifyWebhookSignature ( testPayload , process . env . SIGNING_SECRET );
Use the Dashboard to send test events to your endpoint and inspect delivery logs.
Rotating Your Signing Secret
If your signing secret is compromised:
Go to Dashboard → Settings → Webhook
Click Signing Secret
Update your environment variable with the new secret
The old secret is invalidated immediately
Rotating your secret will cause all in-flight webhooks signed with the old secret to fail verification. Update your application before rotating in production.