Webhook Implementation

Guida tecnica per implementare un endpoint webhook sicuro per ricevere eventi da UniMsg.

Signature Verification

Ogni webhook include una firma HMAC-SHA256 per verificare l'autenticità:

Header di Sicurezza

Header Descrizione
X-UniMsg-Signature HMAC-SHA256 del body
X-UniMsg-Timestamp Unix timestamp della richiesta
X-UniMsg-Event Tipo di evento

Algoritmo di Verifica

// La firma è calcolata come:
signature = HMAC-SHA256(
    key: webhook_secret,
    message: timestamp + "." + raw_body
)

Implementazione PHP

<?php
class WebhookHandler
{
    private string $secret;
    private int $toleranceSeconds = 300; // 5 minuti

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

    public function handle(): void
    {
        $payload = file_get_contents('php://input');
        $signature = $_SERVER['HTTP_X_UNIMSG_SIGNATURE'] ?? '';
        $timestamp = $_SERVER['HTTP_X_UNIMSG_TIMESTAMP'] ?? '';

        // 1. Verifica timestamp (previeni replay attack)
        if (abs(time() - (int)$timestamp) > $this->toleranceSeconds) {
            http_response_code(401);
            exit('Timestamp expired');
        }

        // 2. Calcola firma attesa
        $signedPayload = $timestamp . '.' . $payload;
        $expectedSignature = hash_hmac('sha256', $signedPayload, $this->secret);

        // 3. Confronto timing-safe
        if (!hash_equals($expectedSignature, $signature)) {
            http_response_code(401);
            exit('Invalid signature');
        }

        // 4. Processa evento
        $event = json_decode($payload, true);
        $this->processEvent($event);

        http_response_code(200);
    }

    private function processEvent(array $event): void
    {
        switch ($event['event']) {
            case 'message.delivered':
                $this->handleDelivered($event['data']);
                break;
            case 'message.failed':
                $this->handleFailed($event['data']);
                break;
            // ... altri eventi
        }
    }
}

Implementazione Node.js

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

const app = express();
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;

// Importante: raw body per verifica signature
app.use('/webhook', express.raw({ type: 'application/json' }));

app.post('/webhook', (req, res) => {
    const signature = req.headers['x-unimsg-signature'];
    const timestamp = req.headers['x-unimsg-timestamp'];
    const rawBody = req.body;

    // 1. Verifica timestamp
    if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > TOLERANCE_SECONDS) {
        return res.status(401).send('Timestamp expired');
    }

    // 2. Calcola firma attesa
    const signedPayload = `${timestamp}.${rawBody}`;
    const expectedSignature = crypto
        .createHmac('sha256', WEBHOOK_SECRET)
        .update(signedPayload)
        .digest('hex');

    // 3. Confronto timing-safe
    const signatureBuffer = Buffer.from(signature, 'hex');
    const expectedBuffer = Buffer.from(expectedSignature, 'hex');

    if (!crypto.timingSafeEqual(signatureBuffer, expectedBuffer)) {
        return res.status(401).send('Invalid signature');
    }

    // 4. Processa evento
    const event = JSON.parse(rawBody);
    processEvent(event);

    res.status(200).send('OK');
});

function processEvent(event) {
    console.log(`Received: ${event.event}`, event.data);
    // ... gestione evento
}

Implementazione Python

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

app = Flask(__name__)
WEBHOOK_SECRET = 'your_webhook_secret'
TOLERANCE_SECONDS = 300

@app.route('/webhook', methods=['POST'])
def webhook():
    signature = request.headers.get('X-UniMsg-Signature', '')
    timestamp = request.headers.get('X-UniMsg-Timestamp', '')
    raw_body = request.get_data(as_text=True)

    # 1. Verifica timestamp
    if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        return 'Timestamp expired', 401

    # 2. Calcola firma attesa
    signed_payload = f"{timestamp}.{raw_body}"
    expected_signature = hmac.new(
        WEBHOOK_SECRET.encode(),
        signed_payload.encode(),
        hashlib.sha256
    ).hexdigest()

    # 3. Confronto timing-safe
    if not hmac.compare_digest(signature, expected_signature):
        return 'Invalid signature', 401

    # 4. Processa evento
    event = request.json
    process_event(event)

    return 'OK', 200

def process_event(event):
    print(f"Received: {event['event']}", event['data'])

Idempotency

I webhook possono essere riconsegnati. Usa l'ID evento per deduplicazione:

function processEvent(event) {
    const eventId = event.id;

    // Check se già processato
    if (await redis.exists(`webhook:${eventId}`)) {
        console.log(`Event ${eventId} already processed`);
        return;
    }

    // Processa
    await handleEvent(event);

    // Marca come processato (TTL 7 giorni)
    await redis.setex(`webhook:${eventId}`, 604800, '1');
}

Retry Handling

UniMsg ritenta i webhook falliti con backoff esponenziale:

Tentativo Ritardo Timeout
1Immediato30s
21 min30s
35 min30s
430 min30s
52 ore30s

Il tuo endpoint deve:

Best Practice: Async Processing

Rispondi subito e processa in background:

app.post('/webhook', async (req, res) => {
    // Verifica signature...

    // Rispondi subito
    res.status(200).send('OK');

    // Processa in background
    setImmediate(() => {
        processEvent(JSON.parse(req.body));
    });
});

// Oppure usa una queue
app.post('/webhook', async (req, res) => {
    // Verifica signature...

    // Accoda per elaborazione
    await queue.add('webhook-event', JSON.parse(req.body));

    res.status(200).send('OK');
});

Testing Locale

Per testare webhook in locale, usa ngrok:

# 1. Avvia ngrok
ngrok http 3000

# 2. Usa l'URL ngrok nella dashboard UniMsg
# https://abc123.ngrok.io/webhook

# 3. Invia webhook di test dalla dashboard