Webhook Signature Verification
Secure your webhook endpoints by verifying that requests genuinely come from OilPriceAPI.
Overview
Every webhook request from OilPriceAPI includes a signature in the X-OilPrice-Signature
header. This signature ensures:
- Requests are genuinely from OilPriceAPI
- Payload hasn't been tampered with
- Protection against replay attacks
Signature Algorithm
We use HMAC-SHA256 to sign webhook payloads with your webhook secret.
Signature Format
X-OilPrice-Signature: t=1627849260,v1=5257a869e7ecb3...
Components:
t
: Unix timestamp when signature was generatedv1
: HMAC-SHA256 signature (version 1)
Getting Your Webhook Secret
- Log into dashboard
- Navigate to Webhooks → Configuration
- Copy your webhook secret (starts with
whsec_
) - Store securely in environment variables
# .env
WEBHOOK_SECRET=whsec_a1b2c3d4e5f6g7h8i9j0
Verification Implementation
Node.js/JavaScript
const crypto = require('crypto');
function verifyWebhookSignature(payload, signature, secret) {
// Parse signature header
const elements = signature.split(',');
const timestamp = elements
.find(e => e.startsWith('t='))
?.substring(2);
const receivedSig = elements
.find(e => e.startsWith('v1='))
?.substring(3);
if (!timestamp || !receivedSig) {
throw new Error('Invalid signature format');
}
// Check timestamp (prevent replay attacks)
const currentTime = Math.floor(Date.now() / 1000);
const tolerance = 300; // 5 minutes
if (currentTime - parseInt(timestamp) > tolerance) {
throw new Error('Webhook timestamp too old');
}
// Compute expected signature
const signedPayload = `${timestamp}.${payload}`;
const expectedSig = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// Compare signatures (timing-safe)
if (!crypto.timingSafeEqual(
Buffer.from(receivedSig),
Buffer.from(expectedSig)
)) {
throw new Error('Invalid webhook signature');
}
return true;
}
// Express.js webhook endpoint
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-oilprice-signature'];
const payload = req.body.toString();
try {
verifyWebhookSignature(payload, signature, process.env.WEBHOOK_SECRET);
// Parse and process webhook
const event = JSON.parse(payload);
console.log('Webhook verified:', event);
// Process event asynchronously
processWebhookEvent(event);
// Respond quickly
res.status(200).send('OK');
} catch (err) {
console.error('Webhook verification failed:', err.message);
res.status(401).send('Unauthorized');
}
});
Python
import hmac
import hashlib
import time
import json
from flask import Flask, request, abort
app = Flask(__name__)
def verify_webhook_signature(payload, signature, secret):
"""Verify OilPriceAPI webhook signature"""
# Parse signature header
elements = signature.split(',')
timestamp = None
received_sig = None
for element in elements:
if element.startswith('t='):
timestamp = element[2:]
elif element.startswith('v1='):
received_sig = element[3:]
if not timestamp or not received_sig:
raise ValueError('Invalid signature format')
# Check timestamp (prevent replay attacks)
current_time = int(time.time())
tolerance = 300 # 5 minutes
if current_time - int(timestamp) > tolerance:
raise ValueError('Webhook timestamp too old')
# Compute expected signature
signed_payload = f"{timestamp}.{payload}"
expected_sig = hmac.new(
secret.encode('utf-8'),
signed_payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
# Compare signatures (timing-safe)
if not hmac.compare_digest(received_sig, expected_sig):
raise ValueError('Invalid webhook signature')
return True
@app.route('/webhook', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-OilPrice-Signature')
if not signature:
abort(401, 'Missing signature')
payload = request.data.decode('utf-8')
try:
verify_webhook_signature(
payload,
signature,
os.environ['WEBHOOK_SECRET']
)
# Parse and process webhook
event = json.loads(payload)
print(f"Webhook verified: {event['event']}")
# Process asynchronously
process_webhook_async.delay(event)
return 'OK', 200
except Exception as e:
print(f"Webhook verification failed: {e}")
abort(401, 'Unauthorized')
Ruby
require 'openssl'
require 'json'
require 'time'
class WebhookVerifier
def self.verify(payload, signature, secret)
# Parse signature header
elements = signature.split(',')
timestamp = elements.find { |e| e.start_with?('t=') }&.slice(2..)
received_sig = elements.find { |e| e.start_with?('v1=') }&.slice(3..)
raise 'Invalid signature format' unless timestamp && received_sig
# Check timestamp (prevent replay attacks)
current_time = Time.now.to_i
tolerance = 300 # 5 minutes
if current_time - timestamp.to_i > tolerance
raise 'Webhook timestamp too old'
end
# Compute expected signature
signed_payload = "#{timestamp}.#{payload}"
expected_sig = OpenSSL::HMAC.hexdigest(
'SHA256',
secret,
signed_payload
)
# Compare signatures (timing-safe)
unless Rack::Utils.secure_compare(received_sig, expected_sig)
raise 'Invalid webhook signature'
end
true
end
end
# Rails controller
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def receive
signature = request.headers['X-OilPrice-Signature']
payload = request.raw_post
begin
WebhookVerifier.verify(
payload,
signature,
ENV['WEBHOOK_SECRET']
)
# Parse and process webhook
event = JSON.parse(payload)
Rails.logger.info "Webhook verified: #{event['event']}"
# Process asynchronously
ProcessWebhookJob.perform_later(event)
head :ok
rescue => e
Rails.logger.error "Webhook verification failed: #{e.message}"
head :unauthorized
end
end
end
PHP
<?php
function verifyWebhookSignature($payload, $signature, $secret) {
// Parse signature header
$elements = explode(',', $signature);
$timestamp = null;
$receivedSig = null;
foreach ($elements as $element) {
if (strpos($element, 't=') === 0) {
$timestamp = substr($element, 2);
} elseif (strpos($element, 'v1=') === 0) {
$receivedSig = substr($element, 3);
}
}
if (!$timestamp || !$receivedSig) {
throw new Exception('Invalid signature format');
}
// Check timestamp (prevent replay attacks)
$currentTime = time();
$tolerance = 300; // 5 minutes
if ($currentTime - intval($timestamp) > $tolerance) {
throw new Exception('Webhook timestamp too old');
}
// Compute expected signature
$signedPayload = $timestamp . '.' . $payload;
$expectedSig = hash_hmac('sha256', $signedPayload, $secret);
// Compare signatures (timing-safe)
if (!hash_equals($receivedSig, $expectedSig)) {
throw new Exception('Invalid webhook signature');
}
return true;
}
// Webhook endpoint
$signature = $_SERVER['HTTP_X_OILPRICE_SIGNATURE'] ?? '';
$payload = file_get_contents('php://input');
try {
verifyWebhookSignature($payload, $signature, $_ENV['WEBHOOK_SECRET']);
// Parse and process webhook
$event = json_decode($payload, true);
error_log("Webhook verified: " . $event['event']);
// Process asynchronously
processWebhookAsync($event);
http_response_code(200);
echo 'OK';
} catch (Exception $e) {
error_log("Webhook verification failed: " . $e->getMessage());
http_response_code(401);
echo 'Unauthorized';
}
?>
Security Best Practices
1. Always Verify Signatures
Never process webhooks without verification:
// ❌ INSECURE - Don't do this
app.post('/webhook', (req, res) => {
processEvent(req.body); // No verification!
res.send('OK');
});
// ✅ SECURE - Always verify
app.post('/webhook', (req, res) => {
if (!verifySignature(req)) {
return res.status(401).send('Unauthorized');
}
processEvent(req.body);
res.send('OK');
});
2. Use Environment Variables
Never hardcode webhook secrets:
# ❌ INSECURE
WEBHOOK_SECRET = "whsec_abc123" # Don't hardcode!
# ✅ SECURE
WEBHOOK_SECRET = os.environ.get('WEBHOOK_SECRET')
if not WEBHOOK_SECRET:
raise ValueError('WEBHOOK_SECRET not configured')
3. Implement Replay Protection
Check timestamp to prevent replay attacks:
function checkTimestamp(timestamp) {
const currentTime = Math.floor(Date.now() / 1000);
const age = currentTime - parseInt(timestamp);
// Reject if older than 5 minutes
if (age > 300) {
throw new Error('Webhook too old');
}
// Reject if timestamp is in the future
if (age < -30) {
throw new Error('Webhook timestamp in future');
}
return true;
}
4. Log Failed Verifications
Monitor for potential attacks:
import logging
def handle_webhook():
try:
verify_webhook_signature(...)
# Process webhook
except Exception as e:
# Log with details for security monitoring
logging.warning(
'Webhook verification failed',
extra={
'ip': request.remote_addr,
'signature': request.headers.get('X-OilPrice-Signature'),
'error': str(e),
'timestamp': time.time()
}
)
abort(401)
5. Respond Quickly
Process webhooks asynchronously to avoid timeouts:
app.post('/webhook', async (req, res) => {
// Verify synchronously
if (!verifySignature(req)) {
return res.status(401).send();
}
// Respond immediately
res.status(200).send('OK');
// Process asynchronously
await queue.add('process-webhook', {
event: req.body,
received_at: Date.now()
});
});
Testing Webhook Verification
Generate Test Signatures
import hmac
import hashlib
import time
def generate_test_signature(payload, secret):
"""Generate a valid signature for testing"""
timestamp = str(int(time.time()))
signed_payload = f"{timestamp}.{payload}"
signature = hmac.new(
secret.encode('utf-8'),
signed_payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
return f"t={timestamp},v1={signature}"
# Test your webhook endpoint
test_payload = json.dumps({
"event": "price.updated",
"commodity": "WTI_USD",
"price": 78.50
})
test_signature = generate_test_signature(
test_payload,
"whsec_test123"
)
# Send test request
response = requests.post(
'http://localhost:3000/webhook',
data=test_payload,
headers={
'X-OilPrice-Signature': test_signature,
'Content-Type': 'application/json'
}
)
Test Invalid Signatures
describe('Webhook Verification', () => {
test('rejects missing signature', async () => {
const response = await request(app)
.post('/webhook')
.send({ event: 'test' });
expect(response.status).toBe(401);
});
test('rejects invalid signature', async () => {
const response = await request(app)
.post('/webhook')
.set('X-OilPrice-Signature', 't=123,v1=invalid')
.send({ event: 'test' });
expect(response.status).toBe(401);
});
test('rejects old timestamp', async () => {
const oldTime = Math.floor(Date.now() / 1000) - 400; // 400 seconds old
const response = await request(app)
.post('/webhook')
.set('X-OilPrice-Signature', `t=${oldTime},v1=somesig`)
.send({ event: 'test' });
expect(response.status).toBe(401);
});
test('accepts valid signature', async () => {
const validSig = generateValidSignature(payload, secret);
const response = await request(app)
.post('/webhook')
.set('X-OilPrice-Signature', validSig)
.send(payload);
expect(response.status).toBe(200);
});
});
Troubleshooting
Common Issues
"Invalid signature format"
- Check header name is exactly
X-OilPrice-Signature
- Ensure signature contains both
t=
andv1=
parts - Verify no extra spaces or characters
"Webhook timestamp too old"
- Server time may be out of sync
- Increase tolerance window if network is slow
- Check timezone settings
"Invalid webhook signature"
- Verify webhook secret is correct
- Ensure payload is raw bytes, not parsed JSON
- Check for middleware modifying request body
Debug Logging
Add detailed logging to troubleshoot issues:
function debugWebhook(req) {
console.log('=== Webhook Debug ===');
console.log('Headers:', req.headers);
console.log('Signature:', req.headers['x-oilprice-signature']);
console.log('Body type:', typeof req.body);
console.log('Body:', req.body);
console.log('Secret (first 8 chars):', process.env.WEBHOOK_SECRET?.substring(0, 8));
// Compute signature for debugging
const timestamp = Math.floor(Date.now() / 1000);
const payload = typeof req.body === 'string' ? req.body : JSON.stringify(req.body);
const signed = `${timestamp}.${payload}`;
const sig = crypto.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(signed)
.digest('hex');
console.log('Expected format: t=' + timestamp + ',v1=' + sig);
console.log('===================');
}
Support
For webhook verification issues:
- Email: [email protected]
- Include: Webhook endpoint URL, error messages, signature header
- Response time: 3 business days