Skip to content

Webhooks

Webhooks allow you to receive real-time notifications when events occur in your Invoicetronic API integration. Each webhook includes a secret that you must use to validate the authenticity of received calls.

Security

It is essential to always validate webhook signatures to ensure that requests actually come from Invoicetronic and not from malicious sources.

How Signature Works

When Invoicetronic sends a webhook notification to your endpoint, it includes an HTTP header Invoicetronic-Signature with the following structure:

Invoicetronic-Signature: t=1733395200,v1=a1b2c3d4e5f6789...

Where:

  • t: Unix timestamp in seconds (when the request was generated)
  • v1: HMAC-SHA256 signature calculated as follows:
    1. Concatenate the timestamp, a dot, and the JSON payload: {timestamp}.{jsonPayload}
    2. Calculate HMAC-SHA256 using the webhook secret as the key
    3. Convert the result to lowercase hexadecimal string

Client-Side Validation

To validate a received webhook, you must:

  1. Extract timestamp and signature from the Invoicetronic-Signature header
  2. Verify that the timestamp is not too old (e.g., max 5 minutes)
  3. Recalculate the signature using the secret and compare it with the received one
  4. Use a timing-safe comparison to prevent timing attacks

C# / .NET Example

using System.Security.Cryptography;
using System.Text;

public class WebhookValidator
{
    private readonly string _secret;

    public WebhookValidator(string secret)
    {
        _secret = secret;
    }

    public bool ValidateSignature(string signatureHeader, string payload)
    {
        // Parse the header: "t=1234567890,v1=abcdef..."
        var parts = signatureHeader.Split(',');
        if (parts.Length != 2) return false;

        var timestamp = parts[0].Replace("t=", "");
        var receivedSignature = parts[1].Replace("v1=", "");

        // Verify timestamp is not too old (max 5 minutes)
        var timestampValue = long.Parse(timestamp);
        var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
        if (Math.Abs(now - timestampValue) > 300)
            return false;

        // Calculate expected signature
        var message = $"{timestamp}.{payload}";
        var expectedSignature = ComputeHmacSha256(message, _secret);

        // Timing-safe comparison
        return TimingSafeEqual(expectedSignature, receivedSignature);
    }

    private string ComputeHmacSha256(string message, string secret)
    {
        var encoding = Encoding.UTF8;
        var keyBytes = encoding.GetBytes(secret);
        var messageBytes = encoding.GetBytes(message);

        using var hmac = new HMACSHA256(keyBytes);
        var hashBytes = hmac.ComputeHash(messageBytes);

        return BitConverter.ToString(hashBytes)
            .Replace("-", "")
            .ToLower();
    }

    private bool TimingSafeEqual(string a, string b)
    {
        if (a.Length != b.Length) return false;

        var result = 0;
        for (var i = 0; i < a.Length; i++)
            result |= a[i] ^ b[i];

        return result == 0;
    }
}

// Usage in ASP.NET Core endpoint
[HttpPost("webhook")]
public async Task<IActionResult> HandleWebhook(
    [FromHeader(Name = "Invoicetronic-Signature")] string signature)
{
    // Read body as RAW string
    using var reader = new StreamReader(Request.Body, Encoding.UTF8);
    var payload = await reader.ReadToEndAsync();

    var validator = new WebhookValidator("wh_sec_your_secret_here");

    if (!validator.ValidateSignature(signature, payload))
        return Unauthorized(new { error = "Invalid signature" });

    // Process webhook
    var webhookData = JsonSerializer.Deserialize<WebhookEvent>(payload);

    // ... process the event ...

    return Ok();
}

RAW Body

It's important to read the request body before deserializing it to JSON, to maintain the exact byte representation received.

Node.js / JavaScript Example

const crypto = require('crypto');
const express = require('express');

class WebhookValidator {
    constructor(secret) {
        this.secret = secret;
    }

    validateSignature(signatureHeader, payload) {
        // Parse the header
        const parts = signatureHeader.split(',');
        if (parts.length !== 2) return false;

        const timestamp = parts[0].replace('t=', '');
        const receivedSignature = parts[1].replace('v1=', '');

        // Verify timestamp (max 5 minutes)
        const now = Math.floor(Date.now() / 1000);
        if (Math.abs(now - parseInt(timestamp)) > 300)
            return false;

        // Calculate expected signature
        const message = `${timestamp}.${payload}`;
        const expectedSignature = this.computeHmacSha256(message);

        // Timing-safe comparison
        return this.timingSafeEqual(expectedSignature, receivedSignature);
    }

    computeHmacSha256(message) {
        return crypto
            .createHmac('sha256', this.secret)
            .update(message, 'utf8')
            .digest('hex');
    }

    timingSafeEqual(a, b) {
        if (a.length !== b.length) return false;
        return crypto.timingSafeEqual(
            Buffer.from(a, 'utf8'),
            Buffer.from(b, 'utf8')
        );
    }
}

// Usage in Express
const app = express();
const validator = new WebhookValidator('wh_sec_your_secret_here');

app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
    const signature = req.headers['invoicetronic-signature'];
    const payload = req.body.toString('utf8');

    if (!validator.validateSignature(signature, payload)) {
        return res.status(401).json({ error: 'Invalid signature' });
    }

    // Process webhook
    const webhookData = JSON.parse(payload);

    // ... process the event ...

    res.json({ status: 'ok' });
});

express.raw()

Use express.raw({ type: 'application/json' }) instead of express.json() to maintain the original body needed for validation.

Python Example

import hmac
import hashlib
import time
from flask import Flask, request, jsonify

class WebhookValidator:
    def __init__(self, secret: str):
        self.secret = secret

    def validate_signature(self, signature_header: str, payload: str) -> bool:
        # Parse the header
        parts = signature_header.split(',')
        if len(parts) != 2:
            return False

        timestamp = parts[0].replace('t=', '')
        received_signature = parts[1].replace('v1=', '')

        # Verify timestamp (max 5 minutes)
        now = int(time.time())
        if abs(now - int(timestamp)) > 300:
            return False

        # Calculate expected signature
        message = f"{timestamp}.{payload}"
        expected_signature = self.compute_hmac_sha256(message)

        # Timing-safe comparison
        return hmac.compare_digest(expected_signature, received_signature)

    def compute_hmac_sha256(self, message: str) -> str:
        return hmac.new(
            self.secret.encode('utf-8'),
            message.encode('utf-8'),
            hashlib.sha256
        ).hexdigest()

# Usage in Flask
app = Flask(__name__)
validator = WebhookValidator('wh_sec_your_secret_here')

@app.route('/webhook', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('Invoicetronic-Signature')
    payload = request.get_data(as_text=True)

    if not validator.validate_signature(signature, payload):
        return jsonify({'error': 'Invalid signature'}), 401

    # Process webhook
    webhook_data = request.get_json()

    # ... process the event ...

    return jsonify({'status': 'ok'})

request.get_data()

Use request.get_data(as_text=True) before request.get_json() to get the raw payload needed for validation.

PHP Example

<?php

class WebhookValidator
{
    private string $secret;

    public function __construct(string $secret)
    {
        $this->secret = $secret;
    }

    public function validateSignature(string $signatureHeader, string $payload): bool
    {
        // Parse the header: "t=1234567890,v1=abcdef..."
        $parts = explode(',', $signatureHeader);
        if (count($parts) !== 2) {
            return false;
        }

        $timestamp = str_replace('t=', '', $parts[0]);
        $receivedSignature = str_replace('v1=', '', $parts[1]);

        // Verify timestamp is not too old (max 5 minutes)
        $now = time();
        if (abs($now - (int)$timestamp) > 300) {
            return false;
        }

        // Calculate expected signature
        $message = $timestamp . '.' . $payload;
        $expectedSignature = $this->computeHmacSha256($message);

        // Timing-safe comparison
        return hash_equals($expectedSignature, $receivedSignature);
    }

    private function computeHmacSha256(string $message): string
    {
        return hash_hmac('sha256', $message, $this->secret);
    }
}

// Usage in standalone PHP script or with frameworks
$validator = new WebhookValidator('wh_sec_your_secret_here');

// Read the header
$signature = $_SERVER['HTTP_INVOICETRONIC_SIGNATURE'] ?? '';

// Read RAW body (important!)
$payload = file_get_contents('php://input');

if (!$validator->validateSignature($signature, $payload)) {
    http_response_code(401);
    header('Content-Type: application/json');
    echo json_encode(['error' => 'Invalid signature']);
    exit;
}

// Process webhook
$webhookData = json_decode($payload, true);

// ... process the event ...

http_response_code(200);
header('Content-Type: application/json');
echo json_encode(['status' => 'ok']);

file_get_contents('php://input')

Use file_get_contents('php://input') to read the raw request body. Don't use $_POST as it doesn't work with application/json.

hash_equals()

PHP provides hash_equals() (since PHP 5.6+) which performs a timing-safe comparison to prevent timing attacks.

Security Best Practices

1. Timestamp Validation

Always verify that the timestamp is not too old to prevent replay attacks:

// Example: max 5 minutes difference
var maxAge = TimeSpan.FromMinutes(5);
var requestAge = DateTimeOffset.UtcNow - DateTimeOffset.FromUnixTimeSeconds(timestamp);
if (requestAge > maxAge)
    return false;

2. Timing-Safe Comparison

Always use timing-safe comparison functions to prevent timing attacks:

  • C#: Implement a custom XOR-based comparison
  • Node.js: crypto.timingSafeEqual()
  • Python: hmac.compare_digest()
  • PHP: hash_equals()

3. Secret Management

The secret is shown only at webhook creation time. Store it securely:

  • Use environment variables
  • Use secret managers (Azure Key Vault, AWS Secrets Manager, etc.)
  • Encrypt secrets in the database
  • Never commit secrets to source code

4. Original Payload

Always validate the payload in its original form before deserializing it:

// CORRECT
var payload = await ReadBodyAsString();
if (!validator.ValidateSignature(signature, payload))
    return Unauthorized();
var data = JsonSerializer.Deserialize<Event>(payload);

// WRONG
var data = await Request.ReadFromJsonAsync<Event>();
if (!validator.ValidateSignature(signature, JsonSerializer.Serialize(data)))
    return Unauthorized(); // Serialization might differ!

5. Automatic Disabling

If you want to disable a webhook, respond with HTTP 410 Gone. Invoicetronic will automatically disable it:

[HttpPost("webhook")]
public IActionResult HandleWebhook()
{
    // If you want to disable the webhook
    if (shouldDisableWebhook)
        return StatusCode(410); // Gone

    // Otherwise process normally
    return Ok();
}

Event Structure

Webhook events follow the Event resource structure (see API Reference):

{
  "id": 12345,
  "user_id": 100,
  "company_id": 42,
  "resource_id": 789,
  "endpoint": "send",
  "method": "POST",
  "status_code": 201,
  "success": true,
  "date_time": "2024-01-20T10:30:00Z",
  "api_version": 1
}

Supported Events

You can register webhooks for the following events:

  • send.add - New invoice sent
  • send.delete - Sent invoice deleted
  • receive.add - New invoice received
  • receive.delete - Received invoice deleted
  • update.add - New status update
  • update.delete - Status update deleted
  • company.add - New company created
  • company.delete - Company deleted
  • * - All events

Testing Webhooks

During development, you can use services like:

Sandbox Environment

Remember that you can test webhooks in the sandbox environment using your test API key (ik_test_...).

Troubleshooting

Webhook not arriving

  1. Verify that the URL is publicly reachable
  2. Check that the webhook is enabled (enabled: true)
  3. Verify that the event is in the list of registered events
  4. Check webhook history in the dashboard

Invalid signature

  1. Make sure to read the body before deserializing it
  2. Verify you're using the correct secret (starts with wh_sec_)
  3. Check that signature comparison is timing-safe
  4. Verify that encoding is ASCII for HMAC-SHA256

Webhook gets disabled

Invoicetronic automatically disables webhooks that respond with HTTP 410 Gone. If you don't want a webhook to be disabled, make sure to respond with other status codes even in case of errors (e.g., 200, 500).