OilPriceAPI Documentation
GitHub
GitHub
  • Guides

    • Authentication
    • Testing & Development
    • Error Codes Reference
    • Webhook Signature Verification
    • Production Deployment Checklist
    • Service Level Agreement (SLA)
    • Rate Limiting & Response Headers
    • Data Quality and Validation
    • Troubleshooting Guide
    • Incident Response Guide

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 generated
  • v1: HMAC-SHA256 signature (version 1)

Getting Your Webhook Secret

  1. Log into dashboard
  2. Navigate to Webhooks → Configuration
  3. Copy your webhook secret (starts with whsec_)
  4. 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= and v1= 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
Prev
Error Codes Reference
Next
Production Deployment Checklist