Webhook
In alternativa al polling degli endpoint entity/..., puoi ricevere una notifica HTTP POST quando lo stato di
un pagamento, di una dilazione o di un abbonamento cambia. Il webhook evita di dover interrogare ripetutamente le API
per sapere se il cliente ha completato il pagamento.
Configurazione
Il webhook si imposta in due modi, equivalenti:
- dalla Dashboard, nella sezione Sviluppatori;
- via API, con l'endpoint
POST /esercente/developer_options(vedi Opzioni sviluppatore).
Il webhook_secret con cui vengono firmate le notifiche è generato da JataPay e si legge con
GET /esercente/developer_options oppure dalla Dashboard.
Richiesta inviata da JataPay
A ogni evento JataPay invia una richiesta con questa forma:
POST https://e-commerce.example.com/webhook
Content-Type: application/json; charset=utf-8
X-Webhook-Signature: t=1730000000,v1=5c1f9d2e...e7
Il corpo è il JSON dell'evento. Rispondi con un codice 2xx per confermare la ricezione: ogni altra risposta viene
considerata un errore di consegna.
Calcolo della firma
La firma è un HMAC-SHA256 calcolato sul corpo esatto della richiesta:
- il corpo viene serializzato una sola volta in JSON compatto (separatori
,e:, chiavi ordinate alfabeticamente) e inviato così com'è, codificato in UTF-8; - si firma la stringa
timestamp + "." + body, dovetimestampsono i secondi Unix dell'invio ebodysono i byte esatti del corpo; - il digest esadecimale viene inviato nell'header
X-Webhook-Signaturenella format=<timestamp>,v1=<firma>.
Se il webhook non ha un secret, l'header X-Webhook-Signature non viene inviato.
Verificare la firma
- Verifica la firma sul corpo grezzo della richiesta: non riserializzare il JSON, altrimenti la firma non corrisponde più.
- Confronta le firme con una funzione a tempo costante (
hash_equals,compare_digest,timingSafeEqual,hmac.Equal,FixedTimeEquals,MessageDigest.isEqual). - Rifiuta i timestamp troppo vecchi (ad esempio oltre 5 minuti) per limitare gli attacchi di replay.
- PHP
- Python
- Node.js
- Go
- .NET
- Java
$secret = 'IL_WEBHOOK_SECRET';
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
if (!preg_match('/^t=(\d+),v1=([a-f0-9]{64})$/', $header, $matches)) {
http_response_code(400);
exit('Firma mancante');
}
$timestamp = $matches[1];
$firma = $matches[2];
// timestamp . "." . corpo grezzo
$atteso = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
if (!hash_equals($atteso, $firma)) {
http_response_code(401);
exit('Firma non valida');
}
http_response_code(200);
import hashlib
import hmac
import re
from flask import Flask, request
app = Flask(__name__)
SECRET = "IL_WEBHOOK_SECRET"
@app.post("/webhook")
def webhook():
raw_body = request.get_data() # i byte esatti ricevuti
header = request.headers.get("X-Webhook-Signature", "")
match = re.fullmatch(r"t=(\d+),v1=([a-f0-9]{64})", header)
if not match:
return "Firma mancante", 400
timestamp, firma = match.group(1), match.group(2)
atteso = hmac.new(
SECRET.encode("utf-8"),
f"{timestamp}.".encode("utf-8") + raw_body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(atteso, firma):
return "Firma non valida", 401
return "", 200
const crypto = require('node:crypto');
const express = require('express');
const app = express();
const secret = 'IL_WEBHOOK_SECRET';
// express.raw mantiene il corpo come Buffer, senza riserializzarlo
app.post('/webhook', express.raw({type: 'application/json'}), (req, res) => {
const rawBody = req.body;
const header = req.get('X-Webhook-Signature') || '';
const match = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(header);
if (!match) {
return res.status(400).send('Firma mancante');
}
const [, timestamp, firma] = match;
const atteso = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest('hex');
const valida =
atteso.length === firma.length &&
crypto.timingSafeEqual(Buffer.from(atteso), Buffer.from(firma));
if (!valida) {
return res.status(401).send('Firma non valida');
}
res.sendStatus(200);
});
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"regexp"
)
var firmaRe = regexp.MustCompile(`^t=(\d+),v1=([a-f0-9]{64})$`)
func webhook(secret string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rawBody, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "corpo non leggibile", http.StatusBadRequest)
return
}
parti := firmaRe.FindStringSubmatch(r.Header.Get("X-Webhook-Signature"))
if parti == nil {
http.Error(w, "firma mancante", http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(parti[1] + "."))
mac.Write(rawBody)
atteso := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(atteso), []byte(parti[2])) {
http.Error(w, "firma non valida", http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func main() {
http.HandleFunc("/webhook", webhook("IL_WEBHOOK_SECRET"))
http.ListenAndServe(":8080", nil)
}
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
var app = WebApplication.CreateBuilder(args).Build();
const string secret = "IL_WEBHOOK_SECRET";
app.MapPost("/webhook", async (HttpRequest request) =>
{
using var reader = new StreamReader(request.Body, Encoding.UTF8);
var rawBody = await reader.ReadToEndAsync();
var header = request.Headers["X-Webhook-Signature"].ToString();
var match = Regex.Match(header, @"^t=(\d+),v1=([a-f0-9]{64})$");
if (!match.Success)
{
return Results.Text("Firma mancante", statusCode: 400);
}
var timestamp = match.Groups[1].Value;
var firma = match.Groups[2].Value;
var signed = Encoding.UTF8.GetBytes($"{timestamp}.{rawBody}");
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var atteso = Convert.ToHexString(hmac.ComputeHash(signed)).ToLowerInvariant();
var valida = CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(atteso),
Encoding.UTF8.GetBytes(firma));
return valida
? Results.Ok()
: Results.Text("Firma non valida", statusCode: 401);
});
app.Run();
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class WebhookServlet extends HttpServlet {
private static final String SECRET = "IL_WEBHOOK_SECRET";
private static final Pattern SIGNATURE = Pattern.compile("^t=(\\d+),v1=([a-f0-9]{64})$");
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws java.io.IOException {
byte[] rawBody = req.getInputStream().readAllBytes();
String header = req.getHeader("X-Webhook-Signature");
Matcher matcher = header == null ? null : SIGNATURE.matcher(header);
if (matcher == null || !matcher.matches()) {
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "Firma mancante");
return;
}
String timestamp = matcher.group(1);
String firma = matcher.group(2);
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
String atteso = HexFormat.of().formatHex(mac.doFinal(rawBody));
if (!MessageDigest.isEqual(
atteso.getBytes(StandardCharsets.UTF_8),
firma.getBytes(StandardCharsets.UTF_8))) {
resp.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Firma non valida");
return;
}
} catch (Exception e) {
resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Verifica non riuscita");
return;
}
resp.setStatus(HttpServletResponse.SC_NO_CONTENT);
}
}
Esempio di verifica
Supponendo che il webhook invii {"importo":2000,"stato":"paid"} con timestamp 1730000000, la stringa firmata è:
1730000000.{"importo":2000,"stato":"paid"}
e la firma da confrontare è l'header X-Webhook-Signature: t=1730000000,v1=<firma>.
.md da allegare a un assistente AI per integrare le API