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
signnel 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
{
"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:
{
"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) |
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:
- Estrai il campo
signdal payload - Rimuovi il campo
signdall'oggetto - Codifica i campi rimanenti in JSON
- Codifica il JSON in Base64
- Calcola HMAC-SHA256 dalla stringa Base64 utilizzando la tua API_KEY
- Confronta la firma calcolata con il valore di
signutilizzando un confronto a tempo costante
<?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);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);
});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}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)
}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
endVerifica 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.
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
{
"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) |
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) |
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_idouuid). 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
signprima di fidarti del payload.
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:
- Leggi il corpo della richiesta senza registrare segreti o la firma completa.
- 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.
- Rimuovi
sign, riproduci i byte JSON documentati, calcola HMAC-SHA256 e confrontalo in tempo costante. - Valida gli identificatori richiesti, le stringhe decimali e i valori di stato.
- Inserisci in modo atomico un record inbox/idempotency. Se esiste già, restituisci HTTP 200 senza ripetere effetti collaterali.
- Confermare la mutazione dell'ordine/libro mastro e mettere in coda le email, le analisi o le notifiche non critiche.
- 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
checkperché un evento precedente è arrivato in ritardo; - consentire a
underpaid_checkdi ricevere txid aggiuntivi senza ripetere accrediti precedenti; - trattare
paideoverpaidcome stati di regolamento riusciti, pur mantenendo i loro importi differenti; - mantenere
underpaidcome risultato finale di pagamento parziale a meno che l’API autorevole non riporti successivamente un altro stato; - indirizzare
aml_locka 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.