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

Validatione examples

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.

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.

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

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.

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;

public class WebhookValidator {
    private final String secret;

    public WebhookValidator(String secret) {
        this.secret = secret;
    }

    public boolean validateSignature(String signatureHeader, String payload) {
        try {
            // Parse the header: "t=1234567890,v1=abcdef..."
            String[] parts = signatureHeader.split(",");
            if (parts.length != 2) {
                return false;
            }

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

            // Verify timestamp is not too old (max 5 minutes)
            long timestampValue = Long.parseLong(timestamp);
            long now = Instant.now().getEpochSecond();
            if (Math.abs(now - timestampValue) > 300) {
                return false;
            }

            // Calculate expected signature
            String message = timestamp + "." + payload;
            String expectedSignature = computeHmacSha256(message);

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

        } catch (Exception e) {
            return false;
        }
    }

    private String computeHmacSha256(String message)
            throws NoSuchAlgorithmException, InvalidKeyException {
        Mac hmac = Mac.getInstance("HmacSHA256");
        SecretKeySpec secretKey = new SecretKeySpec(
            secret.getBytes(StandardCharsets.UTF_8),
            "HmacSHA256"
        );
        hmac.init(secretKey);
        byte[] hashBytes = hmac.doFinal(message.getBytes(StandardCharsets.UTF_8));

        // Convert to lowercase hexadecimal
        StringBuilder hexString = new StringBuilder();
        for (byte b : hashBytes) {
            String hex = Integer.toHexString(0xff & b);
            if (hex.length() == 1) hexString.append('0');
            hexString.append(hex);
        }
        return hexString.toString();
    }

    private boolean timingSafeEqual(String a, String b) {
        if (a.length() != b.length()) {
            return false;
        }

        int result = 0;
        for (int i = 0; i < a.length(); i++) {
            result |= a.charAt(i) ^ b.charAt(i);
        }
        return result == 0;
    }
}

// Usage in Spring Boot
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.Map;

@RestController
public class WebhookController {
    private final WebhookValidator validator =
        new WebhookValidator("wh_sec_your_secret_here");

    @PostMapping("/webhook")
    public ResponseEntity<?> handleWebhook(
            @RequestHeader("Invoicetronic-Signature") String signature,
            @RequestBody String payload) {

        if (!validator.validateSignature(signature, payload)) {
            return ResponseEntity
                .status(HttpStatus.UNAUTHORIZED)
                .body(Map.of("error", "Invalid signature"));
        }

        // Process webhook
        // WebhookEvent event = objectMapper.readValue(payload, WebhookEvent.class);

        // ... process the event ...

        return ResponseEntity.ok(Map.of("status", "ok"));
    }
}

@RequestBody String

Use @RequestBody String instead of deserializing directly to an object to maintain the raw payload needed for validation.

require 'openssl'
require 'json'

class WebhookValidator
  def initialize(secret)
    @secret = secret
  end

  def validate_signature(signature_header, payload)
    # Parse the header
    parts = signature_header.split(',')
    return false if parts.length != 2

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

    # Verify timestamp (max 5 minutes)
    now = Time.now.to_i
    return false if (now - timestamp.to_i).abs > 300

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

    # Timing-safe comparison
    timing_safe_equal(expected_signature, received_signature)
  end

  private

  def compute_hmac_sha256(message)
    OpenSSL::HMAC.hexdigest('sha256', @secret, message)
  end

  def timing_safe_equal(a, b)
    return false if a.length != b.length

    # Ruby 2.5.1+ has Rack::Utils.secure_compare
    # For earlier versions, we implement XOR comparison
    result = 0
    a.bytes.zip(b.bytes) { |x, y| result |= x ^ y }
    result.zero?
  end
end

# Usage in Sinatra
require 'sinatra'

validator = WebhookValidator.new('wh_sec_your_secret_here')

post '/webhook' do
  signature = request.env['HTTP_INVOICETRONIC_SIGNATURE']
  payload = request.body.read

  unless validator.validate_signature(signature, payload)
    status 401
    return { error: 'Invalid signature' }.to_json
  end

  # Process webhook
  webhook_data = JSON.parse(payload)

  # ... process the event ...

  content_type :json
  { status: 'ok' }.to_json
end

# Usage in Rails
# class WebhooksController < ApplicationController
#   skip_before_action :verify_authenticity_token
#
#   def create
#     validator = WebhookValidator.new('wh_sec_your_secret_here')
#     signature = request.headers['Invoicetronic-Signature']
#     payload = request.raw_post
#
#     unless validator.validate_signature(signature, payload)
#       render json: { error: 'Invalid signature' }, status: :unauthorized
#       return
#     end
#
#     webhook_data = JSON.parse(payload)
#     # ... process the event ...
#
#     render json: { status: 'ok' }
#   end
# end

request.body.read / request.raw_post

In Sinatra use request.body.read, in Rails use request.raw_post to get the raw payload before JSON parsing.

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/hex"
    "encoding/json"
    "io"
    "math"
    "net/http"
    "strconv"
    "strings"
    "time"
)

type WebhookValidator struct {
    secret string
}

func NewWebhookValidator(secret string) *WebhookValidator {
    return &WebhookValidator{secret: secret}
}

func (v *WebhookValidator) ValidateSignature(signatureHeader, payload string) bool {
    // Parse the header: "t=1234567890,v1=abcdef..."
    parts := strings.Split(signatureHeader, ",")
    if len(parts) != 2 {
        return false
    }

    timestamp := strings.TrimPrefix(parts[0], "t=")
    receivedSignature := strings.TrimPrefix(parts[1], "v1=")

    // Verify timestamp is not too old (max 5 minutes)
    timestampValue, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil {
        return false
    }
    now := time.Now().Unix()
    if math.Abs(float64(now-timestampValue)) > 300 {
        return false
    }

    // Calculate expected signature
    message := timestamp + "." + payload
    expectedSignature := v.computeHmacSha256(message)

    // Timing-safe comparison
    return subtle.ConstantTimeCompare(
        []byte(expectedSignature),
        []byte(receivedSignature),
    ) == 1
}

func (v *WebhookValidator) computeHmacSha256(message string) string {
    h := hmac.New(sha256.New, []byte(v.secret))
    h.Write([]byte(message))
    return hex.EncodeToString(h.Sum(nil))
}

// Usage in HTTP handler
func main() {
    validator := NewWebhookValidator("wh_sec_your_secret_here")

    http.HandleFunc("/webhook", func(w http.ResponseWriter, r *http.Request) {
        // Read the header
        signature := r.Header.Get("Invoicetronic-Signature")

        // Read RAW body
        bodyBytes, err := io.ReadAll(r.Body)
        if err != nil {
            http.Error(w, "Error reading body", http.StatusBadRequest)
            return
        }
        payload := string(bodyBytes)

        // Validate signature
        if !validator.ValidateSignature(signature, payload) {
            w.Header().Set("Content-Type", "application/json")
            w.WriteHeader(http.StatusUnauthorized)
            json.NewEncoder(w).Encode(map[string]string{
                "error": "Invalid signature",
            })
            return
        }

        // Process webhook
        var webhookData map[string]interface{}
        if err := json.Unmarshal(bodyBytes, &webhookData); err != nil {
            http.Error(w, "Invalid JSON", http.StatusBadRequest)
            return
        }

        // ... process the event ...

        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(map[string]string{
            "status": "ok",
        })
    })

    http.ListenAndServe(":8080", nil)
}

io.ReadAll(r.Body)

Read the body with io.ReadAll(r.Body) before deserializing, so you can use the raw bytes for validation.

subtle.ConstantTimeCompare()

Go provides crypto/subtle.ConstantTimeCompare() for native timing-safe comparisons.

import crypto from 'crypto';
import express, { Request, Response } from 'express';

class WebhookValidator {
    private secret: string;

    constructor(secret: string) {
        this.secret = secret;
    }

    validateSignature(signatureHeader: string, payload: string): boolean {
        // 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);
    }

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

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

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

interface WebhookEvent {
    id: number;
    user_id: number;
    company_id: number;
    resource_id: number;
    endpoint: string;
    method: string;
    status_code: number;
    success: boolean;
    date_time: string;
    api_version: number;
}

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

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

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

        // ... process the event ...

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

app.listen(3000, () => {
    console.log('Webhook server listening on port 3000');
});

Type safety

TypeScript allows you to define interfaces for webhook data, improving type safety and IDE autocomplete.

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).