# Notifiche Webhook

> Ricevi aggiornamenti in tempo reale sullo stato di pagamenti e prelievi tramite webhook firmati con HMAC.

Il sistema 2328.io invia un webhook al tuo `url_callback` ogni volta che cambia lo stato di un pagamento. Questo è il modo consigliato per essere notificati dei pagamenti andati a buon fine.

## Formato della richiesta

- **Method:** `POST`
- **Content-Type:** `application/json`
- **Signature:** campo `sign` nel body della richiesta

## Payload

Il body del webhook segue il formato della risposta di `/v1/payment/info` e aggiunge `tx_explorer_url`, oltre al campo `sign` usato per verificare la firma.

### Pagamento andato a buon fine

```json
{
  "uuid": "db17d490-15b6-47b9-9015-91d1d8b119f2",
  "order_id": "ORDER-12345",
  "amount": "180.00000000",
  "currency": "RUB",
  "url": "https://go.2328.io/db17d490-15b6-47b9-9015-91d1d8b119f2",
  "expires_at": "2026-05-09T16:56:58+03:00",
  "created_at": "2026-05-09T15:56:58+03:00",
  "payer_currency": "TON",
  "payer_amount": "0.95256917",
  "network": "TON",
  "address": "UQA0RevhkCQx-EltyNgPPeG8dqtnCz7ZslOzMdNQlLxVaNBb",
  "payment_status": "paid",
  "txid": "41c2a327323480af8e705d05deb09c238a41779928832abef4bb77c862357b11",
  "tx_explorer_url": "https://tonviewer.com/transaction/41c2a327323480af8e705d05deb09c238a41779928832abef4bb77c862357b11",
  "payment_amount": "0.95256917",
  "merchant_amount": "0.949711462490000000",
  "amount_usd": "2.41324380",
  "exchange_rate": "0.01340691",
  "sign": "6f8c15b6e53b506d5bfa38ed3fb3b50697af73434262153c02e412541372f04d"
}
```

### Pagamento annullato / fallito

Quando il pagamento non si trova in uno stato terminale `paid`, `txid`, `payment_amount` e `merchant_amount` sono `null`:

```json
{
  "uuid": "48edaf2d-2c49-4638-8f86-88636f661c1f",
  "order_id": "ORDER-12345",
  "amount": "2800.00000000",
  "currency": "RUB",
  "url": "https://go.2328.io/48edaf2d-2c49-4638-8f86-88636f661c1f",
  "expires_at": "2026-05-09T06:19:04+03:00",
  "created_at": "2026-05-09T05:19:04+03:00",
  "payer_currency": "ETH",
  "payer_amount": "0.01620968",
  "network": "ETH-ERC20",
  "address": "0x37c20d6d96d130Bc5B33D832e43b8e16aACe0c59",
  "payment_status": "cancel",
  "txid": null,
  "tx_explorer_url": null,
  "payment_amount": null,
  "merchant_amount": null,
  "amount_usd": "37.53934800",
  "exchange_rate": "0.01340691",
  "sign": "40ce68ad9691ad54e684329d75ab5adaf5b01409a2d18d3e0110b8c1be605342"
}
```

### Riferimento dei campi

| Campo | Tipo | Descrizione |
|-------|------|-------------|
| `uuid` | string | UUID del pagamento |
| `order_id` | string | Il tuo ID ordine |
| `amount` | decimal (8 dp) | Importo fiat in `currency` |
| `currency` | string | Valuta fiat richiesta dal merchant |
| `url` | string | URL del checkout ospitato |
| `expires_at` | string (ISO 8601) | Quando scade la sessione di pagamento |
| `created_at` | string (ISO 8601) | Quando è stata creata la sessione di pagamento |
| `payer_currency` | string | Crypto con cui sta pagando il pagatore |
| `payer_amount` | decimal (8 dp) | Importo crypto atteso |
| `network` | string | Rete blockchain |
| `address` | string | Indirizzo di deposito |
| `payment_status` | string | Uno tra: `pending`, `check`, `paid`, `underpaid_check`, `underpaid`, `overpaid`, `cancel`, `aml_lock` (vedi [References](/docs/references)) |
| `txid` | string \| null | Hash della transazione blockchain, presente solo dopo un pagamento confermato |
| `tx_explorer_url` | string \| null | URL della transazione nell’explorer blockchain. È `null` se `txid` è assente o se il trasferimento è P2P interno. |
| `payment_amount` | decimal \| null | Importo effettivamente pagato, presente solo dopo il pagamento |
| `merchant_amount` | decimal (18 dp) \| null | Importo accreditato al merchant dopo le commissioni |
| `amount_usd` | decimal (8 dp) | Importo in USD al momento della creazione |
| `exchange_rate` | decimal | Tasso di cambio crypto / fiat utilizzato |
| `sign` | string (hex) | Firma HMAC-SHA256 del payload |

## Verifica della firma

Per verificare la firma di un webhook:

1. Estrai il campo `sign` dal payload
2. Rimuovi il campo `sign` dall'oggetto
3. Codifica i campi rimanenti in JSON
4. Codifica il JSON in Base64
5. Calcola HMAC-SHA256 dalla stringa Base64 utilizzando la tua API_KEY
6. Confronta la firma calcolata con il valore di `sign` utilizzando un confronto a tempo costante

#### php

```php
<?php
function verifyWebhookSign(array $data, string $apiKey): bool {
    $receivedSign = $data['sign'] ?? '';
    unset($data['sign']);

    $json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    $base64 = base64_encode($json);
    $calculated = hash_hmac('sha256', $base64, $apiKey);

    return hash_equals($calculated, $receivedSign);
}

$apiKey = 'YOUR_API_KEY';
$payload = json_decode(file_get_contents('php://input'), true);

if (!verifyWebhookSign($payload, $apiKey)) {
    http_response_code(401);
    exit;
}

switch ($payload['payment_status']) {
    case 'paid':
    case 'overpaid':
        // Credit the order — check idempotency by order_id first
        break;
    case 'underpaid_check':
    case 'underpaid':
    case 'cancel':
        break;
}

http_response_code(200);
```

#### js

```js
import crypto from "crypto";
import express from "express";

const app = express();
app.use(express.json());

function verifyWebhookSign(payload, apiKey) {
  const { sign, ...rest } = payload;
  const json = JSON.stringify(rest);
  const base64 = Buffer.from(json).toString("base64");
  const calculated = crypto
    .createHmac("sha256", apiKey)
    .update(base64)
    .digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(calculated),
    Buffer.from(sign || ""),
  );
}

app.post("/webhook", (req, res) => {
  if (!verifyWebhookSign(req.body, process.env.API_KEY)) {
    return res.sendStatus(401);
  }

  const { order_id, payment_status, txid } = req.body;

  if (payment_status === "paid" || payment_status === "overpaid") {
    // Credit the order — check idempotency by order_id first
  }

  res.sendStatus(200);
});
```

#### python

```python
import json
import hmac
import hashlib
import base64
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
API_KEY = "YOUR_API_KEY"

def verify_webhook_sign(payload: dict, api_key: str) -> bool:
    received = payload.pop("sign", "")
    body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
    b64 = base64.b64encode(body.encode("utf-8")).decode()
    calculated = hmac.new(api_key.encode(), b64.encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(calculated, received)

@app.post("/webhook")
async def webhook(request: Request):
    payload = await request.json()
    if not verify_webhook_sign(payload, API_KEY):
        raise HTTPException(401)

    if payload["payment_status"] in ("paid", "overpaid"):
        # Credit the order — check idempotency by order_id first
        pass

    return {"ok": True}
```

#### go

```go
package main

import (
    "bytes"
    "crypto/hmac"
    "crypto/sha256"
    "encoding/base64"
    "encoding/hex"
    "encoding/json"
    "io"
    "net/http"
)

func verifyWebhookSign(body []byte, apiKey string) (map[string]any, bool) {
    var payload map[string]any
    if err := json.Unmarshal(body, &payload); err != nil {
        return nil, false
    }
    received, _ := payload["sign"].(string)
    delete(payload, "sign")

    var buf bytes.Buffer
    enc := json.NewEncoder(&buf)
    enc.SetEscapeHTML(false)
    enc.Encode(payload)
    reencoded := bytes.TrimRight(buf.Bytes(), "\n")

    b64 := base64.StdEncoding.EncodeToString(reencoded)
    h := hmac.New(sha256.New, []byte(apiKey))
    h.Write([]byte(b64))
    calculated := hex.EncodeToString(h.Sum(nil))

    return payload, hmac.Equal([]byte(calculated), []byte(received))
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    payload, ok := verifyWebhookSign(body, apiKey)
    if !ok {
        http.Error(w, "invalid signature", http.StatusUnauthorized)
        return
    }

    status, _ := payload["payment_status"].(string)
    if status == "paid" || status == "overpaid" {
        // Credit the order — check idempotency first
    }
    w.WriteHeader(http.StatusOK)
}
```

#### ruby

```ruby
require "json"
require "openssl"
require "base64"
require "sinatra"

API_KEY = "YOUR_API_KEY"

def verify_webhook_sign(payload, api_key)
  received = payload.delete("sign") || ""
  body = payload.to_json
  b64 = Base64.strict_encode64(body)
  calculated = OpenSSL::HMAC.hexdigest("SHA256", api_key, b64)
  OpenSSL.fixed_length_secure_compare(calculated, received)
end

post "/webhook" do
  payload = JSON.parse(request.body.read)
  halt 401 unless verify_webhook_sign(payload, API_KEY)

  if %w[paid overpaid].include?(payload["payment_status"])
    # Credit the order — check idempotency by order_id first
  end

  status 200
end
```

> **DANGER:** **Verifica sempre la firma** prima di accreditare qualsiasi fondo a un utente. Un webhook non firmato o firmato in modo errato potrebbe essere una richiesta contraffatta.

## Webhook dei prelievi

Quando lo `status` di un prelievo cambia, il sistema invia un webhook `POST` all'URL `url_callback` passato al momento della creazione del prelievo. Se `url_callback` non è stato fornito, non viene inviato alcun webhook per quel prelievo.

> **WARNING:** I webhook dei prelievi devono essere verificati con la tua **Payout API key** — non con l'API key normale. L'algoritmo di firma è identico a quello dei webhook di pagamento (rimuovi `sign`, codifica in JSON, base64, HMAC-SHA256), cambia solo la chiave.

### Payload

```json
{
  "uuid": "019dff1f-0dbd-7277-8d45-271e7775388f",
  "order_id": "4dfdcc84402b1185b71cbe399321533e",
  "status": "completed",
  "currency": "TRX",
  "network": "TRX-TRC20",
  "amount": "3.00",
  "merchant_amount": "3.00",
  "network_amount": "3.00",
  "amount_usd": "1.04",
  "to_address": "THauRv5tcucQRohXg8NiyGTk16DX1XQG5x",
  "memo": null,
  "txid": "9242e533703704ef3eaba840f70b4a26333e72c943377ee375fea17badb53def",
  "tx_explorer_url": "https://tronscan.org/#/transaction/9242e533703704ef3eaba840f70b4a26333e72c943377ee375fea17badb53def",
  "block_number": null,
  "error_type": null,
  "created_at": "2026-05-07T00:08:38+03:00",
  "updated_at": "2026-05-07T00:08:54+03:00",
  "from_currency": "USDT",
  "debited_amount": "1.050735",
  "debited_currency": "USDT",
  "sign": "925ad7bf3d6841864101f7cc2c7e30652e70a06cdb04dbe07a0129480000ce4a"
}
```

### Riferimento dei campi

| Campo | Tipo | Descrizione |
|-------|------|-------------|
| `uuid` | string | UUID del prelievo |
| `order_id` | string | Il tuo ID di idempotenza / riferimento, se ne hai fornito uno |
| `status` | string | `pending`, `completed`, `failed`, `cancelled` (vedi [References](/docs/references)) |
| `currency` | string | Valuta del prelievo |
| `network` | string | Rete blockchain |
| `amount` | decimal | Importo del prelievo (in `currency`) |
| `merchant_amount` | decimal | Importo addebitato dal saldo merchant |
| `network_amount` | decimal | Importo effettivamente inviato on-chain |
| `amount_usd` | decimal | Valore in USD al momento del prelievo |
| `to_address` | string | Indirizzo blockchain del destinatario |
| `memo` | string \| null | Memo / destination tag, se utilizzato |
| `txid` | string \| null | Hash della transazione blockchain, valorizzato a `completed` |
| `tx_explorer_url` | string \| null | URL della transazione nell’explorer blockchain. È `null` se `txid` è assente o se il trasferimento è P2P interno. |
| `block_number` | integer \| null | Altezza del blocco della transazione on-chain |
| `error_type` | string \| null | Motivo quando `status = failed` (es. `aml_risk`, vedi [References](/docs/references)) |
| `created_at` | string (ISO 8601) | Quando è stato creato il prelievo |
| `updated_at` | string (ISO 8601) | Quando è cambiato per l'ultima volta lo stato |
| `from_currency` | string | Saldo di origine da cui è stato addebitato il prelievo quando è stata usata la conversione automatica (es. `USDT` per un prelievo in `BTC`) |
| `debited_amount` | decimal | Importo addebitato dal saldo `from_currency` |
| `debited_currency` | string | Valuta dell'addebito |
| `sign` | string (hex) | Firma HMAC-SHA256 del payload, firmata con la **Payout API key** |

## Best practice

- **Idempotenza** — Verifica sempre se il pagamento è già stato elaborato (tramite `order_id` o `uuid`). I webhook possono arrivare più volte.
- **Risposta rapida** — Restituisci HTTP 200 il più rapidamente possibile. Delega il lavoro pesante a una coda in background.
- **Retry** — Se il sistema non riceve un HTTP 200, il webhook viene rinviato dopo 2 minuti. Massimo 5 tentativi di retry.
- **Elaborazione asincrona** — Gestisci gli eventi webhook in modo asincrono per evitare di bloccare la risposta.
- **Sicurezza** — Verifica SEMPRE la firma `sign` prima di fidarti del payload.

> **WARNING:** I webhook possono arrivare in ordine non sequenziale. Non dare per scontato che il primo webhook ricevuto sia lo stato finale — recupera sempre nuovamente tramite `/v1/payment/info` (o `/v1/payout/status/{uuid}`) se hai bisogno di certezza.

## Contratto di consegna e elaborazione

Usa il seguente ordine all'interno del tuo endpoint webhook:

1. Leggi il corpo della richiesta senza registrare segreti o la firma completa.
2. Identifica se si tratta di un evento di pagamento/wallet statico o di un evento di pagamento in uscita, in modo da selezionare la chiave API corretta.
3. Rimuovi `sign`, riproduci i byte JSON documentati, calcola HMAC-SHA256 e confrontalo in tempo costante.
4. Valida gli identificatori richiesti, le stringhe decimali e i valori di stato.
5. Inserisci in modo atomico un record inbox/idempotency. Se esiste già, restituisci HTTP 200 senza ripetere effetti collaterali.
6. Confermare la mutazione dell'ordine/libro mastro e mettere in coda le email, le analisi o le notifiche non critiche.
7. Restituire rapidamente HTTP 200.

Non chiamare servizi di terze parti lenti mentre si tiene la transazione di idempotenza. Un timeout dopo aver confermato ma prima di restituire può causare un nuovo tentativo; il duplicato deve osservare la chiave inbox confermata e diventare un'operazione nulla.

### Chiavi di idempotenza consigliate

| Evento | Identità primaria | Note |
|-------|------------------|-------|
| Sessione di pagamento | `uuid` + evidenza di stato/versione | La stessa fattura può emettere più cambiamenti di stato legittimi. |
| Ricarica a pagamento parziale | fattura `uuid` + `txid` | Più di un bonifico può appartenere alla stessa fattura sotto-pagata. |
| Deposito in portafoglio statico | rete + `txid` | `order_id` viene riutilizzato per ogni deposito in quel portafoglio. |
| Pagamento | pagamento `uuid` + stato | Non creare mai un secondo pagamento dalla logica di ripetizione del webhook. |

Se il tuo schema non ha un id evento, memorizza l'hash del payload verificato come ulteriore prova di audit, ma non sostituire le identità aziendali sopra con un timestamp.

## Ordinamento e riconciliazione

La consegna è almeno una volta e i messaggi di stato possono arrivare in ordine casuale. Implementare regole aziendali monotone piuttosto che “vince l’ultima richiesta”:

- non spostare mai un ordine evaso indietro a `check` perché un evento precedente è arrivato in ritardo;
- consentire a `underpaid_check` di ricevere txid aggiuntivi senza ripetere accrediti precedenti;
- trattare `paid` e `overpaid` come stati di regolamento riusciti, pur mantenendo i loro importi differenti;
- mantenere `underpaid` come risultato finale di pagamento parziale a meno che l’API autorevole non riporti successivamente un altro stato;
- indirizzare `aml_lock` a revisione e non consentire a un generico lavoratore di ripetizione di evaderlo;
- interroga le informazioni di pagamento/pagamento ogni volta che la transizione è impossibile, manca il contesto o è finanziariamente ambigua.

Esegui la riconciliazione programmata anche quando la consegna del webhook sembra funzionare correttamente. Confronta lo stato del tuo terminale locale e l'importo accreditato con `/v1/payment/info`, `/v1/static-wallet/transactions` o `/v1/payout/status/{uuid}` e segnala le differenze invece di sovrascrivere silenziosamente la cronologia del libro contabile.

## Sicurezza del endpoint webhook

- Richiedi HTTPS e mantieni il callback accessibile pubblicamente; i target di callback privati/loopback vengono rifiutati durante la creazione del pagamento.
- Applica un piccolo limite alla dimensione del corpo della richiesta e tipo di contenuto JSON.
- Limita la frequenza prima di lavori costosi, ma lascia spazio sufficiente per picchi legittimi e tentativi di ripetizione.
- Non autorizzare mai un webhook solo in base all'indirizzo IP di origine. Le liste di permessi di rete sono una difesa in profondità; la verifica HMAC è obbligatoria.
- Censura `sign`, chiavi API, indirizzi quando richiesto dalla policy e metadati personali dai log dell'applicazione.
- Tieni disponibili sia le chiavi correnti che quelle di sostituzione programmate esplicitamente durante una finestra di rotazione controllata; non indovinare mai quale chiave ha firmato un evento.
- Restituisci un corpo di errore generico in caso di firme non valide in modo che l'endpoint non diventi un oracolo di chiavi o account.