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
| Evento | Quando viene emesso |
|---|---|
dossier.sent | OTP inviato al firmatario subito dopo la creazione |
dossier.viewed | Il firmatario ha aperto la pagina di firma |
dossier.signed | OTP verificato, PDF sigillato e marcato temporalmente |
dossier.expired | Link scaduto senza completamento |
dossier.cancelled | Dossier annullato via API |
dossier.failed | Errore 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" }
}signedDocumentUrledevidenceUrlsono presenti solo per l'eventodossier.signed. Sono URL pre-firmati con validità 2 ore. Scarica i file subito e archiviali nel tuo sistema.metadatacontiene 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:
- Leggi il body raw (prima di qualsiasi parsing JSON, perché la firma è calcolata sui byte testuali esatti).
- Estrai
timestampev1dall'headerX-EzySign-Signature. - Costruisci la stringa
<timestamp>.<body>e calcolaHMAC_SHA256(shared_secret, signed_string). - Confronta il digest con
v1usando un confronto a tempo costante. - Verifica che il
timestampnon sia più vecchio di 5 minuti per mitigare i replay attack. - 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", 200Esempio 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.