# 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 `sign` dans 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

```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"
}
```

### Paiement annulé / échoué

Lorsque le paiement n'est pas dans un état terminal `paid`, `txid`, `payment_amount` et `merchant_amount` valent `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"
}
```

### 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](/docs/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 :

1. Extrayez le champ `sign` du payload
2. Supprimez le champ `sign` de l'objet
3. Encodez les champs restants en JSON
4. Encodez le JSON en Base64
5. Calculez HMAC-SHA256 à partir de la chaîne Base64 en utilisant votre API_KEY
6. Comparez la signature calculée à la valeur `sign` à l'aide d'une comparaison à temps constant

#### 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:** **Vé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.

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

```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"
}
```

### 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](/docs/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](/docs/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_id` ou `uuid`). 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 `sign` avant de faire confiance au payload.

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

1. Lisez le corps de la requête sans enregistrer les secrets ni la signature complète.
2. 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.
3. Supprimez `sign`, reproduisez les octets JSON documentés, calculez le HMAC-SHA256 et comparez en temps constant.
4. Validez les identifiants requis, les chaînes décimales et les valeurs de statut.
5. 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.
6. 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.
7. 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 `check` parce 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 `paid` et `overpaid` comme des états de règlement réussis, tout en conservant leurs montants différents ;
- conserver `underpaid` comme un résultat final de paiement partiel sauf si l'API autoritaire rapporte ultérieurement un autre état ;
- rediriger `aml_lock` vers 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.