The TradeFlow API now implements HMAC-SHA256 signature verification for webhook payloads. This prevents malicious actors from spoofing webhook events and ensures data integrity.
- Cryptographic Signing: Uses HMAC-SHA256 to generate signatures
- Constant-Time Comparison: Prevents timing attacks that could reveal information about the correct signature
- Header Validation: Requires
X-Signatureheader in all webhook requests - Payload Integrity: Detects any tampering with the request body
Add the following to your .env file:
WEBHOOK_SECRET="your_long_random_secret_key_here"Guidelines for WEBHOOK_SECRET:
- Minimum 32 characters recommended
- Use a cryptographically random value (e.g., generated with
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))") - Store securely in your infrastructure
- Rotate periodically
- Never commit to source control
# Using OpenSSL
openssl rand -hex 32
# Using Node.js
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"The webhook endpoint automatically validates signatures:
@Post('soroban')
@UseGuards(HmacSignatureGuard)
async handleSorobanEvent(@Body() eventData: any) {
// Only reached if signature verification passes
console.log('Webhook received:', eventData);
return { status: 'success' };
}Response Codes:
200 OK: Signature valid, event processed400 Bad Request: Missing X-Signature header or empty body401 Unauthorized: Invalid or mismatched signature
When sending webhooks to the TradeFlow API, follow this pattern:
const crypto = require('crypto');
const https = require('https');
const WEBHOOK_URL = 'https://api.tradeflow.com/api/v1/webhook/soroban';
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
function generateWebhookSignature(payload, secret) {
return crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
}
function sendWebhook(eventData, jwtToken) {
// Convert payload to JSON string for consistent hashing
const payload = JSON.stringify(eventData);
// Generate HMAC signature
const signature = generateWebhookSignature(payload, WEBHOOK_SECRET);
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Signature': signature,
'Authorization': `Bearer ${jwtToken}`,
'Content-Length': Buffer.byteLength(payload)
},
};
const req = https.request(WEBHOOK_URL, options, (res) => {
let data = '';
res.on('data', chunk => { data += chunk; });
res.on('end', () => {
console.log(`Webhook response [${res.statusCode}]:`, data);
});
});
req.on('error', error => {
console.error('Webhook send error:', error);
});
req.write(payload);
req.end();
}
// Usage
const swapEvent = {
eventId: 'evt_' + Date.now(),
type: 'swap',
contractId: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4',
timestamp: new Date().toISOString(),
data: {
pool: 'POOL_ABC',
amountIn: '1000',
amountOut: '950',
trader: 'GBUQWP3BOUZX34ULNQG23RQ6F4BVWCIRUVOEAK663KSCTXYXO7KKXR7H'
}
};
sendWebhook(swapEvent, process.env.JWT_TOKEN);import json
import hmac
import hashlib
import requests
WEBHOOK_URL = 'https://api.tradeflow.com/api/v1/webhook/soroban'
WEBHOOK_SECRET = os.getenv('WEBHOOK_SECRET')
JWT_TOKEN = os.getenv('JWT_TOKEN')
def generate_signature(payload, secret):
"""Generate HMAC-SHA256 signature"""
return hmac.new(
secret.encode(),
payload.encode() if isinstance(payload, str) else payload,
hashlib.sha256
).hexdigest()
def send_webhook(event_data):
"""Send webhook with HMAC signature"""
payload = json.dumps(event_data)
signature = generate_signature(payload, WEBHOOK_SECRET)
headers = {
'Content-Type': 'application/json',
'X-Signature': signature,
'Authorization': f'Bearer {JWT_TOKEN}'
}
response = requests.post(WEBHOOK_URL, data=payload, headers=headers)
print(f'Webhook response [{response.status_code}]:', response.json())
return response
# Usage
swap_event = {
'eventId': f'evt_{int(time.time() * 1000)}',
'type': 'swap',
'contractId': 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4',
'timestamp': datetime.now().isoformat(),
'data': {
'pool': 'POOL_ABC',
'amountIn': '1000',
'amountOut': '950',
'trader': 'GBUQWP3BOUZX34ULNQG23RQ6F4BVWCIRUVOEAK663KSCTXYXO7KKXR7H'
}
}
send_webhook(swap_event)Always hash the exact raw request body as a string, not the parsed JSON object:
❌ WRONG:
const payload = JSON.stringify(eventData);
const signature = crypto.createHmac('sha256', secret)
.update(JSON.stringify(eventData)) // Different formatting could result
.digest('hex');✅ CORRECT:
const payload = JSON.stringify(eventData);
const signature = crypto.createHmac('sha256', secret)
.update(payload) // Same exact bytes every time
.digest('hex');Ensure consistent JSON formatting when serializing:
// Use this for predictable serialization:
JSON.stringify(eventData)
// Avoid methods that might introduce formatting variations:
JSON.stringify(eventData, null, 2) // Extra whitespace
eventData.toString() // Object methodBoth sides must use UTF-8 encoding:
// JavaScript defaults to UTF-8
const buffer = Buffer.from(payload, 'utf8');
const signature = crypto.createHmac('sha256', secret).update(buffer).digest('hex');The server uses constant-time comparison:
// DO NOT use simple string comparison:
if (computedSig === providedSig) { } // ❌ Vulnerable to timing attacks
// Server uses constant-time comparison:
private constantTimeCompare(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i); // Bitwise comparison
}
return result === 0;
}# Start the development server
npm run start:dev
# In another terminal, run the test suite
WEBHOOK_SECRET="your_webhook_secret_key_change_me" node test-webhook-hmac.js# Generate a signature
PAYLOAD='{"eventId":"evt_123","type":"swap","data":{"amountIn":"1000"}}'
SECRET="your_webhook_secret_key_change_me"
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hex -mac HMAC -macopt key:$SECRET | awk '{print $NF}')
# Send webhook
curl -X POST http://localhost:3000/api/v1/webhook/soroban \
-H "Content-Type: application/json" \
-H "X-Signature: $SIGNATURE" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-d "$PAYLOAD"The test suite (test-webhook-hmac.js) validates:
- ✅ Valid Signature: Correctly signed payload is accepted
- ❌ Invalid Signature: Incorrect signature is rejected with 401
- ❌ Missing Header: Request without X-Signature header is rejected
- ❌ Tampered Payload: Modified payload with original signature is rejected
Problem: Server returns 401 even with what seems like correct signature
Causes & Solutions:
-
JSON Formatting Differences
// ❌ Wrong - Extra spaces affect hash JSON.stringify(data, null, 2) // ✅ Correct - Compact JSON JSON.stringify(data)
-
Encoding Mismatch
// Ensure UTF-8 const signature = crypto.createHmac('sha256', secret) .update(payload, 'utf8') .digest('hex');
-
Secret Mismatch
- Verify WEBHOOK_SECRET matches exactly on both sides
- Check for whitespace or encoding issues
Problem: "Missing X-Signature header" or "Request body is empty"
Solutions:
- Always include
X-Signatureheader - Ensure request body is not empty
- Check header name case (should be lowercase:
x-signature)
-
Idempotency: Use
eventIdto prevent duplicate processingconst eventId = event.eventId; const isProcessed = await db.isEventProcessed(eventId); if (isProcessed) return { status: 'already_processed' };
-
Retry Logic: Implement exponential backoff
const retryDelays = [1000, 2000, 4000, 8000, 16000]; // ms for (const delay of retryDelays) { try { // Send webhook break; } catch (error) { await new Promise(r => setTimeout(r, delay)); } }
-
Logging: Log all webhook events for audit trail
console.log('[WEBHOOK]', { eventId: event.eventId, timestamp: event.timestamp, type: event.type, status: 'received' });
-
Monitoring: Alert on failed webhook deliveries
if (response.statusCode !== 200) { console.error('[WEBHOOK_ERROR]', { statusCode: response.statusCode, body: response.body }); // Send alert to monitoring service }
- Rotate Secrets Regularly: Update WEBHOOK_SECRET every 90 days
- Use HTTPS: Always use HTTPS in production
- Validate Event IDs: Prevent replay attacks with idempotency checks
- Rate Limiting: Server enforces 50 requests/minute per IP
- Audit Logging: Log all webhook processing for security review
Enable debug logging in webhook receiver:
// In webhook.controller.ts
async handleSorobanEvent(@Body() eventData: any) {
console.log('Raw payload size:', JSON.stringify(eventData).length);
console.log('Event ID:', eventData.eventId);
console.log('Event Type:', eventData.type);
// ... rest of handler
}For sender-side debugging:
// Generate and log signature details
const payload = JSON.stringify(eventData);
const signature = generateSignature(payload, WEBHOOK_SECRET);
console.log('[WEBHOOK_DEBUG]', {
payloadSize: payload.length,
payloadHash: crypto.createHash('sha256').update(payload).digest('hex'),
signature: signature.substring(0, 16) + '...',
timestamp: new Date().toISOString()
});