Vai al contenuto

Webhooks

I webhook ti permettono di ricevere notifiche in tempo reale quando si verificano eventi nella tua integrazione con Invoicetronic API. Ogni webhook include un secret che devi usare per validare l'autenticità delle chiamate ricevute.

Sicurezza

È fondamentale validare sempre la firma dei webhook per garantire che le richieste provengano effettivamente da Invoicetronic e non da fonti malevole.

Come funziona la firma

Quando Invoicetronic invia una notifica webhook al tuo endpoint, include un header HTTP Invoicetronic-Signature con la seguente struttura:

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

Dove:

  • t: Unix timestamp in secondi (quando è stata generata la richiesta)
  • v1: Firma HMAC-SHA256 calcolata come segue:
    1. Concatena il timestamp, un punto e il payload JSON: {timestamp}.{jsonPayload}
    2. Calcola HMAC-SHA256 usando il secret del webhook come chiave
    3. Converti il risultato in stringa esadecimale minuscola

Validazione lato client

Per validare un webhook ricevuto, devi:

  1. Estrarre timestamp e firma dall'header Invoicetronic-Signature
  2. Verificare che il timestamp non sia troppo vecchio (es. max 5 minuti)
  3. Ricalcolare la firma usando il secret e confrontarla con quella ricevuta
  4. Usare un confronto sicuro contro timing attacks

Esempi di validazione

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 l'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=", "");

        // Verifica che il timestamp non sia troppo vecchio (max 5 minuti)
        var timestampValue = long.Parse(timestamp);
        var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
        if (Math.Abs(now - timestampValue) > 300)
            return false;

        // Calcola la firma attesa
        var message = $"{timestamp}.{payload}";
        var expectedSignature = ComputeHmacSha256(message, _secret);

        // Confronto sicuro contro timing attacks
        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;
    }
}

// Uso nell'endpoint ASP.NET Core
[HttpPost("webhook")]
public async Task<IActionResult> HandleWebhook(
    [FromHeader(Name = "Invoicetronic-Signature")] string signature)
{
    // Leggi il body come stringa RAW
    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" });

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

    // ... elabora l'evento ...

    return Ok();
}

Body RAW

È importante leggere il body della richiesta prima di deserializzarlo in JSON, per mantenere la rappresentazione esatta dei byte ricevuti.

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

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

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

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

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

        // Calcola firma attesa
        const message = `${timestamp}.${payload}`;
        const expectedSignature = this.computeHmacSha256(message);

        // Confronto sicuro
        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')
        );
    }
}

// Uso 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' });
    }

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

    // ... elabora l'evento ...

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

express.raw()

Usa express.raw({ type: 'application/json' }) invece di express.json() per mantenere il body originale necessario alla validazione.

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 l'header
        parts = signature_header.split(',')
        if len(parts) != 2:
            return False

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

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

        # Calcola firma attesa
        message = f"{timestamp}.{payload}"
        expected_signature = self.compute_hmac_sha256(message)

        # Confronto sicuro
        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()

# Uso 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

    # Processa il webhook
    webhook_data = request.get_json()

    # ... elabora l'evento ...

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

request.get_data()

Usa request.get_data(as_text=True) prima di request.get_json() per ottenere il payload raw necessario alla validazione.

<?php

class WebhookValidator
{
    private string $secret;

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

    public function validateSignature(string $signatureHeader, string $payload): bool
    {
        // Parse l'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]);

        // Verifica che il timestamp non sia troppo vecchio (max 5 minuti)
        $now = time();
        if (abs($now - (int)$timestamp) > 300) {
            return false;
        }

        // Calcola la firma attesa
        $message = $timestamp . '.' . $payload;
        $expectedSignature = $this->computeHmacSha256($message);

        // Confronto sicuro contro timing attacks
        return hash_equals($expectedSignature, $receivedSignature);
    }

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

// Uso in uno script PHP standalone o con framework
$validator = new WebhookValidator('wh_sec_your_secret_here');

// Leggi l'header
$signature = $_SERVER['HTTP_INVOICETRONIC_SIGNATURE'] ?? '';

// Leggi il body RAW (importante!)
$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;
}

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

// ... elabora l'evento ...

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

file_get_contents('php://input')

Usa file_get_contents('php://input') per leggere il body raw della richiesta. Non usare $_POST perché non funziona con application/json.

hash_equals()

PHP fornisce hash_equals() (da PHP 5.6+) che effettua un confronto timing-safe per prevenire 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 l'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=", "");

            // Verifica che il timestamp non sia troppo vecchio (max 5 minuti)
            long timestampValue = Long.parseLong(timestamp);
            long now = Instant.now().getEpochSecond();
            if (Math.abs(now - timestampValue) > 300) {
                return false;
            }

            // Calcola la firma attesa
            String message = timestamp + "." + payload;
            String expectedSignature = computeHmacSha256(message);

            // Confronto sicuro contro timing attacks
            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));

        // Converti in esadecimale minuscolo
        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;
    }
}

// Uso 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"));
        }

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

        // ... elabora l'evento ...

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

@RequestBody String

Usa @RequestBody String invece di deserializzare direttamente in un oggetto per mantenere il payload raw necessario alla validazione.

require 'openssl'
require 'json'

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

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

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

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

    # Calcola firma attesa
    message = "#{timestamp}.#{payload}"
    expected_signature = compute_hmac_sha256(message)

    # Confronto sicuro
    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+ ha Rack::Utils.secure_compare
    # Per versioni precedenti, implementiamo il confronto XOR
    result = 0
    a.bytes.zip(b.bytes) { |x, y| result |= x ^ y }
    result.zero?
  end
end

# Uso 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

  # Processa il webhook
  webhook_data = JSON.parse(payload)

  # ... elabora l'evento ...

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

# Uso 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)
#     # ... elabora l'evento ...
#
#     render json: { status: 'ok' }
#   end
# end

request.body.read / request.raw_post

In Sinatra usa request.body.read, in Rails usa request.raw_post per ottenere il payload raw prima del parsing JSON.

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 l'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=")

    // Verifica che il timestamp non sia troppo vecchio (max 5 minuti)
    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
    }

    // Calcola la firma attesa
    message := timestamp + "." + payload
    expectedSignature := v.computeHmacSha256(message)

    // Confronto sicuro contro timing attacks
    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))
}

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

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

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

        // Valida la firma
        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
        }

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

        // ... elabora l'evento ...

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

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

io.ReadAll(r.Body)

Leggi il body con io.ReadAll(r.Body) prima di deserializzarlo, così puoi usare i byte raw per la validazione.

subtle.ConstantTimeCompare()

Go fornisce crypto/subtle.ConstantTimeCompare() per confronti timing-safe nativi.

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 l'header
        const parts = signatureHeader.split(',');
        if (parts.length !== 2) return false;

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

        // Verifica timestamp (max 5 minuti)
        const now = Math.floor(Date.now() / 1000);
        if (Math.abs(now - parseInt(timestamp)) > 300) {
            return false;
        }

        // Calcola firma attesa
        const message = `${timestamp}.${payload}`;
        const expectedSignature = this.computeHmacSha256(message);

        // Confronto sicuro
        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')
        );
    }
}

// Uso in Express con 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' });
        }

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

        // ... elabora l'evento ...

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

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

Type safety

TypeScript ti permette di definire interfacce per i dati del webhook, migliorando la sicurezza dei tipi e l'autocomplete nell'IDE.

Best Practices per la Sicurezza

1. Validazione del Timestamp

Verifica sempre che il timestamp non sia troppo vecchio per prevenire replay attacks:

// Esempio: max 5 minuti di differenza
var maxAge = TimeSpan.FromMinutes(5);
var requestAge = DateTimeOffset.UtcNow - DateTimeOffset.FromUnixTimeSeconds(timestamp);
if (requestAge > maxAge)
    return false;

2. Confronto Sicuro

Usa sempre funzioni di confronto timing-safe per prevenire timing attacks:

  • C#: Implementa un confronto custom XOR-based
  • Node.js: crypto.timingSafeEqual()
  • Python: hmac.compare_digest()
  • PHP: hash_equals()

3. Gestione del Secret

Il secret è mostrato solo al momento della creazione del webhook. Conservalo in modo sicuro:

  • Usa variabili d'ambiente
  • Usa secret manager (Azure Key Vault, AWS Secrets Manager, etc.)
  • Cripta i secret nel database
  • Non committare mai i secret nel codice sorgente

4. Payload Originale

Valida sempre il payload nella sua forma originale prima di deserializzarlo:

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

// SBAGLIATO
var data = await Request.ReadFromJsonAsync<Event>();
if (!validator.ValidateSignature(signature, JsonSerializer.Serialize(data)))
    return Unauthorized(); // La serializzazione potrebbe differire!

5. Disabilitazione Automatica

Se vuoi disabilitare un webhook, rispondi con HTTP 410 Gone. Invoicetronic lo disabiliterà automaticamente:

[HttpPost("webhook")]
public IActionResult HandleWebhook()
{
    // Se vuoi disabilitare il webhook
    if (shouldDisableWebhook)
        return StatusCode(410); // Gone

    // Altrimenti processa normalmente
    return Ok();
}

Struttura dell'Evento

Gli eventi webhook seguono la struttura della risorsa Event (vedi 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
}

Eventi Supportati

Puoi registrare webhook per i seguenti eventi:

  • send.add - Nuova fattura inviata
  • send.delete - Fattura inviata eliminata
  • receive.add - Nuova fattura ricevuta
  • receive.delete - Fattura ricevuta eliminata
  • update.add - Nuovo aggiornamento di stato
  • update.delete - Aggiornamento di stato eliminato
  • company.add - Nuova azienda creata
  • company.delete - Azienda eliminata
  • * - Tutti gli eventi

Testare i Webhook

Durante lo sviluppo, puoi usare servizi come:

Ambiente Sandbox

Ricorda che puoi testare i webhook nell'ambiente sandbox usando la tua API key di test (ik_test_...).

Risoluzione Problemi

Il webhook non arriva

  1. Verifica che l'URL sia raggiungibile pubblicamente
  2. Controlla che il webhook sia abilitato (enabled: true)
  3. Verifica che l'evento sia nella lista degli eventi registrati
  4. Consulta lo storico webhook nella dashboard

Firma non valida

  1. Assicurati di leggere il body prima di deserializzarlo
  2. Verifica di usare il secret corretto (inizia con wh_sec_)
  3. Controlla che il confronto della firma sia timing-safe
  4. Verifica che la codifica sia ASCII per HMAC-SHA256

Il webhook viene disabilitato

Invoicetronic disabilita automaticamente i webhook che rispondono con HTTP 410 Gone. Se non vuoi che un webhook venga disabilitato, assicurati di rispondere con altri status code anche in caso di errore (es. 200, 500).