Webhooks

I webhook permettono al tuo backend di reagire in tempo reale agli eventi sul ciclo di vita di un dossier (apertura del link, firma completata, annullamento, ecc.) senza fare polling. Tutti gli eventi sono firmati con HMAC SHA-256 per garantire autenticità e integrità.

Configurazione

Imposta il campo callbackUrl nel payload di creazione dossier:

{
  "signer":      { ... },
  "otpChannel":  "email",
  "document":    { ... },
  "callbackUrl": "https://miosito.it/webhooks/ezysign"
}

Requisiti dell'endpoint:

  • HTTPS (TLS 1.2+) — gli HTTP semplici non sono accettati
  • Risposta entro 10 secondi con codice 2xx
  • Metodo: PUT (idempotente) — il body è il payload JSON

Metodo HTTP

I webhook EzySign sono inviati in PUT e non in POST. La scelta deriva dal fatto che ogni evento è identificato univocamente da (dossierId, event): poter applicare la stessa richiesta più volte senza effetti collaterali (idempotenza) è utile in caso di retry per timeout o errori transitori del tuo endpoint.

Eventi

EventoQuando viene emesso
dossier.sentOTP inviato al firmatario subito dopo la creazione
dossier.viewedIl firmatario ha aperto la pagina di firma
dossier.signedOTP verificato, PDF sigillato e marcato temporalmente
dossier.expiredLink scaduto senza completamento
dossier.cancelledDossier annullato via API
dossier.failedErrore irreversibile (es. invio OTP impossibile)

Nota: dossier.created è uno stato interno e non viene notificato via webhook — quando ricevi dossier.sent il dossier è già stato creato.

Schema del payload

{
  "event":             "dossier.signed",
  "dossierId":         "8c1e4f2a-1234-...",
  "status":            "signed",
  "occurredAt":        "2026-04-27T10:05:12.000Z",
  "signedDocumentUrl": "https://...presigned...",
  "evidenceUrl":       "https://...presigned...",
  "metadata":          { "orderId": "ORD-42" }
}
  • signedDocumentUrl ed evidenceUrl sono presenti solo per l'evento dossier.signed. Sono URL pre-firmati con validità 2 ore. Scarica i file subito e archiviali nel tuo sistema.
  • metadata contiene esattamente l'oggetto che hai passato in fase di creazione del dossier (utile per correlare l'evento alle entità nel tuo dominio).

Header di firma

Ogni richiesta webhook include due header dedicati:

X-EzySign-Timestamp: 1761560712
X-EzySign-Signature: t=1761560712,v1=5b2a3f7c1e9d8a4b...
  • X-EzySign-Timestamp: epoch in secondi al momento dell'invio.
  • X-EzySign-Signature: HMAC SHA-256 della stringa <timestamp>.<body> usando lo shared secret configurato per il tuo tenant. Se in futuro lo schema di firma cambierà, sarà aggiunta una nuova versione (es. v2=...) accanto a quella corrente.

Verifica della firma

Procedura raccomandata sul tuo endpoint:

  1. Leggi il body raw (prima di qualsiasi parsing JSON, perché la firma è calcolata sui byte testuali esatti).
  2. Estrai timestamp e v1 dall'header X-EzySign-Signature.
  3. Costruisci la stringa <timestamp>.<body> e calcola HMAC_SHA256(shared_secret, signed_string).
  4. Confronta il digest con v1 usando un confronto a tempo costante.
  5. Verifica che il timestamp non sia più vecchio di 5 minuti per mitigare i replay attack.
  6. Solo dopo la verifica, fai il parsing JSON ed elabora il payload.

Esempio Node.js (Express)

import crypto from "crypto";
import express from "express";

const SECRET = process.env.EZYSIGN_WEBHOOK_SECRET;
const app = express();

app.put(
  "/webhooks/ezysign",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const ts        = req.header("x-ezysign-timestamp");
    const signature = req.header("x-ezysign-signature") || "";
    const v1Match   = signature.match(/v1=([a-f0-9]+)/);
    if (!ts || !v1Match) return res.status(400).send("missing signature");

    const expected = crypto
      .createHmac("sha256", SECRET)
      .update(`${ts}.${req.body.toString("utf8")}`)
      .digest("hex");

    const ok = crypto.timingSafeEqual(
      Buffer.from(v1Match[1], "hex"),
      Buffer.from(expected,    "hex"),
    );
    if (!ok) return res.status(401).send("invalid signature");

    const ageSec = Math.floor(Date.now() / 1000) - Number(ts);
    if (ageSec > 300) return res.status(400).send("replay");

    const payload = JSON.parse(req.body.toString("utf8"));
    // ... aggiorna lo stato nel tuo sistema
    res.status(200).send("ok");
  },
);

Esempio Python (Flask)

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

SECRET = b"<webhook_secret>"
app = express = Flask(__name__)

@app.put("/webhooks/ezysign")
def handle():
    ts  = request.headers.get("X-EzySign-Timestamp")
    sig = request.headers.get("X-EzySign-Signature", "")
    v1  = next((p[3:] for p in sig.split(",") if p.startswith("v1=")), None)
    if not ts or not v1:
        abort(400, "missing signature")

    body = request.get_data()
    expected = hmac.new(SECRET, f"{ts}.{body.decode()}".encode(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, v1):
        abort(401, "invalid signature")
    if time.time() - int(ts) > 300:
        abort(400, "replay")

    payload = request.get_json()
    # ... logica applicativa
    return "ok", 200

Esempio PHP

<?php
$secret = getenv('EZYSIGN_WEBHOOK_SECRET');
$body   = file_get_contents('php://input');
$ts     = $_SERVER['HTTP_X_EZYSIGN_TIMESTAMP'] ?? '';
$sigHdr = $_SERVER['HTTP_X_EZYSIGN_SIGNATURE'] ?? '';

preg_match('/v1=([a-f0-9]+)/', $sigHdr, $m);
$v1 = $m[1] ?? '';

$expected = hash_hmac('sha256', "$ts.$body", $secret);
if (!hash_equals($expected, $v1))   { http_response_code(401); exit; }
if (time() - intval($ts) > 300)     { http_response_code(400); exit; }

$payload = json_decode($body, true);
// ... logica applicativa
http_response_code(200);
echo "ok";

Retry e idempotenza

Se il tuo endpoint risponde con un codice diverso da 2xx o non risponde entro 10 secondi, il webhook viene loggato come fallito ma non viene ritentato automaticamente: per ricostruire lo stato puoi sempre interrogare GET /api/v1/dossiers/:id.

È quindi buona pratica trattare ciascun webhook come idempotente: usa (dossierId, event) come chiave di deduplica nel tuo sistema, in modo che la ricezione doppia di uno stesso evento non causi effetti collaterali.

Test in locale

Per testare i webhook in sviluppo puoi usare un tunnel HTTPS pubblico (ngrok, cloudflared, localtunnel) e passare l'URL pubblico come callbackUrl nel payload di creazione del dossier.