Notifications webhook
Recevez en temps réel les mises à jour de statut des paiements et des retraits via des webhooks signés HMAC.
Le système 2328.io envoie un webhook à votre url_callback chaque fois qu'un statut de paiement change. C'est la méthode recommandée pour être notifié des paiements réussis.
Format de la requête
- Méthode :
POST - Content-Type :
application/json - Signature : champ
signdans le corps de la requête
Payload
Le corps du webhook suit le format de la réponse de /v1/payment/info et ajoute tx_explorer_url, ainsi que le champ sign utilisé pour vérifier la signature.
Paiement réussi
{
"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"
}Paiement annulé / échoué
Lorsque le paiement n'est pas dans un état terminal paid, txid, payment_amount et merchant_amount valent 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"
}Référence des champs
| Champ | Type | Description |
|---|---|---|
uuid | string | UUID du paiement |
order_id | string | Votre ID de commande |
amount | decimal (8 dp) | Montant fiat dans currency |
currency | string | Devise fiat demandée par le marchand |
url | string | URL de la page de paiement hébergée |
expires_at | string (ISO 8601) | Date d'expiration de la session de paiement |
created_at | string (ISO 8601) | Date de création de la session de paiement |
payer_currency | string | Crypto utilisée par le payeur |
payer_amount | decimal (8 dp) | Montant en crypto attendu |
network | string | Réseau blockchain |
address | string | Adresse de dépôt |
payment_status | string | L'une des valeurs : pending, check, paid, underpaid_check, underpaid, overpaid, cancel, aml_lock (voir References) |
txid | string | null | Hash de la transaction blockchain, présent uniquement après confirmation du paiement |
tx_explorer_url | string | null | URL de la transaction dans l’explorateur blockchain. Vaut null si txid est absent ou si le transfert est un P2P interne. |
payment_amount | decimal | null | Montant réellement payé, présent uniquement après le paiement |
merchant_amount | decimal (18 dp) | null | Montant crédité au marchand après les frais |
amount_usd | decimal (8 dp) | Montant en USD au moment de la création |
exchange_rate | decimal | Taux de change crypto / fiat utilisé |
sign | string (hex) | Signature HMAC-SHA256 du payload |
Vérification de la signature
Pour vérifier la signature d'un webhook :
- Extrayez le champ
signdu payload - Supprimez le champ
signde l'objet - Encodez les champs restants en JSON
- Encodez le JSON en Base64
- Calculez HMAC-SHA256 à partir de la chaîne Base64 en utilisant votre API_KEY
- Comparez la signature calculée à la valeur
signà l'aide d'une comparaison à temps constant
<?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
endVérifiez toujours la signature avant de créditer des fonds à un utilisateur. Un webhook non signé ou mal signé pourrait être une requête frauduleuse.
Webhooks de retrait
Lorsque le status d'un retrait change, le système envoie un webhook POST à l'URL url_callback fournie lors de la création du retrait. Si url_callback n'a pas été fourni, aucun webhook n'est envoyé pour ce retrait.
Les webhooks de retrait doivent être vérifiés avec votre Payout API key — pas l'API key classique. L'algorithme de signature est identique à celui des webhooks de paiement (retirer sign, encoder en JSON, base64, HMAC-SHA256), seule la clé diffère.
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"
}Référence des champs
| Champ | Type | Description |
|---|---|---|
uuid | string | UUID du retrait |
order_id | string | Votre identifiant d'idempotence / référence, si vous en avez fourni un |
status | string | pending, completed, failed, cancelled (voir References) |
currency | string | Devise du retrait |
network | string | Réseau blockchain |
amount | decimal | Montant du retrait (dans currency) |
merchant_amount | decimal | Montant prélevé sur le solde marchand |
network_amount | decimal | Montant réellement envoyé on-chain |
amount_usd | decimal | Valeur en USD au moment du retrait |
to_address | string | Adresse blockchain du destinataire |
memo | string | null | Mémo / tag de destination, le cas échéant |
txid | string | null | Hash de la transaction blockchain, défini lors du completed |
tx_explorer_url | string | null | URL de la transaction dans l’explorateur blockchain. Vaut null si txid est absent ou si le transfert est un P2P interne. |
block_number | integer | null | Hauteur du bloc de la transaction on-chain |
error_type | string | null | Raison lorsque status = failed (par ex. aml_risk, voir References) |
created_at | string (ISO 8601) | Date de création du retrait |
updated_at | string (ISO 8601) | Date du dernier changement de statut |
from_currency | string | Solde source débité pour le retrait lorsqu'une conversion automatique a été utilisée (par ex. USDT pour un retrait en BTC) |
debited_amount | decimal | Montant débité du solde from_currency |
debited_currency | string | Devise du débit |
sign | string (hex) | Signature HMAC-SHA256 du payload, signée avec la Payout API key |
Bonnes pratiques
- Idempotence — Vérifiez toujours si le paiement a déjà été traité (par
order_idouuuid). Les webhooks peuvent arriver plusieurs fois. - Réponse rapide — Renvoyez HTTP 200 le plus rapidement possible. Déléguez le traitement lourd à une file d'attente en arrière-plan.
- Retentatives — Si le système ne reçoit pas de HTTP 200, le webhook est renvoyé après 2 minutes. Maximum 5 tentatives.
- Traitement asynchrone — Traitez les événements webhook de manière asynchrone pour éviter de bloquer la réponse.
- Sécurité — Vérifiez TOUJOURS la signature
signavant de faire confiance au payload.
Les webhooks peuvent arriver dans le désordre. Ne supposez pas que le premier webhook reçu est l'état final — récupérez toujours les données via /v1/payment/info (ou /v1/payout/status/{uuid}) si vous avez besoin de certitude.
Contrat de livraison et de traitement
Utilisez l'ordre suivant dans votre point de terminaison webhook :
- Lisez le corps de la requête sans enregistrer les secrets ni la signature complète.
- Identifiez s'il s'agit d'un événement de paiement/de portefeuille statique ou d'un événement de paiement afin de sélectionner la clé API correcte.
- Supprimez
sign, reproduisez les octets JSON documentés, calculez le HMAC-SHA256 et comparez en temps constant. - Validez les identifiants requis, les chaînes décimales et les valeurs de statut.
- Insérez de manière atomique un enregistrement de boîte de réception/idempotence. S'il existe déjà, renvoyez HTTP 200 sans répéter les effets secondaires.
- Valider la mutation de la commande/du grand livre et mettre en file d'attente les e-mails non critiques, l'analytique ou les notifications.
- Retourner rapidement HTTP 200.
Ne pas appeler de services tiers lents pendant la transaction d'idempotence. Un délai après la validation mais avant le retour peut entraîner une nouvelle tentative ; le doublon doit observer la clé de boîte de réception validée et devenir une opération nulle.
Clés d'idempotence recommandées
| Événement | Identité principale | Notes |
|---|---|---|
| Session de paiement | uuid + preuve de statut/version | La même facture peut émettre plusieurs changements de statut légitimes. |
| Recharge partielle de paiement | facture uuid + txid | Plus d'un virement peut appartenir à la même facture sous-payée. |
| Dépôt de portefeuille statique | réseau + txid | order_id est réutilisé pour chaque dépôt vers ce portefeuille. |
| Paiement | paiement uuid + statut | Ne créez jamais un deuxième paiement à partir de la logique de reprise du webhook. |
Si votre schéma n'a pas d'identifiant d'événement, stockez le hash de la charge utile vérifiée comme preuve d'audit supplémentaire, mais ne remplacez pas les identités commerciales ci-dessus par un horodatage.
Commande et réconciliation
La livraison est au moins une fois et les messages d'état peuvent arriver dans le désordre. Implémentez des règles commerciales monotones plutôt que « le dernier traitement l'emporte » :
- ne jamais déplacer une commande exécutée vers
checkparce qu'un événement plus ancien est arrivé en retard ; - autoriser
underpaid_checkà recevoir des txids supplémentaires sans répéter les crédits précédents ; - considérer
paidetoverpaidcomme des états de règlement réussis, tout en conservant leurs montants différents ; - conserver
underpaidcomme un résultat final de paiement partiel sauf si l'API autoritaire rapporte ultérieurement un autre état ; - rediriger
aml_lockvers la révision et ne pas permettre à un opérateur de réessai générique de l'exécuter ; - interroger les informations de paiement/de retrait chaque fois que la transition est impossible, que le contexte est manquant ou financièrement ambigu.
Exécuter la réconciliation programmée même lorsque la livraison du webhook semble saine. Comparez votre état terminal local et le montant crédité avec /v1/payment/info, /v1/static-wallet/transactions ou /v1/payout/status/{uuid} et alertez sur les différences au lieu d'écraser silencieusement l'historique du grand livre.
Sécurité du point de terminaison webhook
- Exiger HTTPS et maintenir le rappel accessible publiquement ; les cibles de rappel privées/boucle locale sont rejetées lors de la création du paiement.
- Imposer une petite limite de taille pour le corps de la requête et le type de contenu JSON.
- Limiter le débit avant un travail coûteux, mais laisser suffisamment de marge pour les pointes légitimes et les réessais.
- Ne jamais autoriser un webhook uniquement par l'IP source. Les listes blanches réseau sont une défense en profondeur ; la vérification HMAC est obligatoire.
- Redigez
sign, les clés API, les adresses lorsque requis par la politique, et les métadonnées personnelles dans les journaux d'application. - Gardez à disposition à la fois les clés actuelles et celles explicitement prévues pour le remplacement durant une fenêtre de rotation contrôlée ; ne devinez jamais quelle clé a signé un événement.
- Renvoyez un corps d'erreur générique en cas de signatures invalides afin que le point de terminaison ne devienne pas un oracle de clé ou de compte.