# 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](https://dashboard.jatapay.it/sviluppatori)**;
- via API, con l'endpoint `POST /esercente/developer_options` (vedi [Opzioni sviluppatore](/riferimento-api/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:

```http
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:

1. il corpo viene serializzato **una sola volta** in JSON compatto (separatori `,` e `:`, chiavi ordinate
   alfabeticamente) e inviato così com'è, codificato in UTF-8;
2. si firma la stringa `timestamp + "." + body`, dove `timestamp` sono i secondi Unix dell'invio e `body` sono i byte
   esatti del corpo;
3. il digest esadecimale viene inviato nell'header `X-Webhook-Signature` nella forma `t=<timestamp>,v1=<firma>`.

Se il webhook non ha un secret, l'header `X-Webhook-Signature` non viene inviato.

## Verificare la firma

:::caution
- 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**

```php
$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);
```

**Python**

```python
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
```

**Node.js**

```javascript
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);
});
```

**Go**

```go
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)
}
```

**.NET**

```csharp
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();
```

**Java**

```java
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 è:

```text
1730000000.{"importo":2000,"stato":"paid"}
```

e la firma da confrontare è l'header `X-Webhook-Signature: t=1730000000,v1=<firma>`.
