# Webhook-Benachrichtigungen

> Erhalten Sie Statusaktualisierungen für Zahlungen und Auszahlungen in Echtzeit über HMAC-signierte Webhooks.

Das System von 2328.io sendet einen Webhook an Ihre `url_callback`, sobald sich der Status einer Zahlung ändert. Dies ist die empfohlene Methode, um über erfolgreiche Zahlungen benachrichtigt zu werden.

## Anfrageformat

- **Methode:** `POST`
- **Content-Type:** `application/json`
- **Signatur:** Feld `sign` im Anfrage-Body

## Payload

Der Webhook-Body folgt dem Format der Antwort von `/v1/payment/info` und ergänzt `tx_explorer_url` sowie das `sign`-Feld zur Signaturprüfung.

### Erfolgreiche Zahlung

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

### Stornierte / fehlgeschlagene Zahlung

Wenn sich die Zahlung nicht im finalen Zustand `paid` befindet, sind `txid`, `payment_amount` und `merchant_amount` `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"
}
```

### Feldreferenz

| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `uuid` | string | Zahlungs-UUID |
| `order_id` | string | Ihre Bestell-ID |
| `amount` | decimal (8 dp) | Fiat-Betrag in `currency` |
| `currency` | string | Vom Händler angeforderte Fiat-Währung |
| `url` | string | URL des gehosteten Checkouts |
| `expires_at` | string (ISO 8601) | Zeitpunkt, an dem die Zahlungssitzung abläuft |
| `created_at` | string (ISO 8601) | Zeitpunkt, an dem die Zahlungssitzung erstellt wurde |
| `payer_currency` | string | Krypto, in der der Zahler bezahlt |
| `payer_amount` | decimal (8 dp) | Erwarteter Krypto-Betrag |
| `network` | string | Blockchain-Netzwerk |
| `address` | string | Einzahlungsadresse |
| `payment_status` | string | Einer von: `pending`, `check`, `paid`, `underpaid_check`, `underpaid`, `overpaid`, `cancel`, `aml_lock` (siehe [References](/docs/references)) |
| `txid` | string \| null | Hash der Blockchain-Transaktion, nur nach bestätigter Zahlung vorhanden |
| `tx_explorer_url` | string \| null | URL der Transaktion im Blockchain-Explorer. `null`, wenn keine `txid` vorhanden ist oder es sich um einen internen P2P-Transfer handelt. |
| `payment_amount` | decimal \| null | Tatsächlich gezahlter Betrag, nur nach Zahlung vorhanden |
| `merchant_amount` | decimal (18 dp) \| null | Dem Händler nach Gebühren gutgeschriebener Betrag |
| `amount_usd` | decimal (8 dp) | Betrag in USD zum Zeitpunkt der Erstellung |
| `exchange_rate` | decimal | Verwendeter Krypto-/Fiat-Wechselkurs |
| `sign` | string (hex) | HMAC-SHA256-Signatur des payload |

## Signatur verifizieren

So verifizieren Sie eine Webhook-Signatur:

1. Extrahieren Sie das Feld `sign` aus dem payload
2. Entfernen Sie das Feld `sign` aus dem Objekt
3. Codieren Sie die übrigen Felder als JSON
4. Codieren Sie das JSON in base64
5. Berechnen Sie HMAC-SHA256 vom Base64-String unter Verwendung Ihres API_KEY
6. Vergleichen Sie die berechnete Signatur mit dem Wert `sign` mittels eines konstantzeitigen Vergleichs

#### 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:** **Verifizieren Sie immer die Signatur**, bevor Sie einem Nutzer Mittel gutschreiben. Ein nicht oder falsch signierter Webhook könnte eine gefälschte Anfrage sein.

## Auszahlungs-Webhooks

Wenn sich der `status` einer Auszahlung ändert, sendet das System einen `POST`-Webhook an die `url_callback`-URL, die bei der Erstellung der Auszahlung übergeben wurde. Wurde keine `url_callback` angegeben, werden für diese Auszahlung keine Webhooks gesendet.

> **WARNING:** Auszahlungs-Webhooks müssen mit Ihrem **Payout-API-Schlüssel** verifiziert werden — nicht mit dem regulären API-Schlüssel. Der Signieralgorithmus ist identisch zu dem für Zahlungs-Webhooks (Feld `sign` entfernen, JSON-codieren, base64, HMAC-SHA256), nur der Schlüssel ist ein anderer.

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

### Feldreferenz

| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `uuid` | string | Auszahlungs-UUID |
| `order_id` | string | Ihre Idempotenz- / Referenz-ID, sofern angegeben |
| `status` | string | `pending`, `completed`, `failed`, `cancelled` (siehe [References](/docs/references)) |
| `currency` | string | Auszahlungswährung |
| `network` | string | Blockchain-Netzwerk |
| `amount` | decimal | Auszahlungsbetrag (in `currency`) |
| `merchant_amount` | decimal | Vom Händlerguthaben belasteter Betrag |
| `network_amount` | decimal | Tatsächlich on-chain gesendeter Betrag |
| `amount_usd` | decimal | USD-Wert zum Zeitpunkt der Auszahlung |
| `to_address` | string | Blockchain-Adresse des Empfängers |
| `memo` | string \| null | Memo / Destination-Tag, falls verwendet |
| `txid` | string \| null | Hash der Blockchain-Transaktion, gesetzt bei `completed` |
| `tx_explorer_url` | string \| null | URL der Transaktion im Blockchain-Explorer. `null`, wenn keine `txid` vorhanden ist oder es sich um einen internen P2P-Transfer handelt. |
| `block_number` | integer \| null | Blockhöhe der on-chain-Transaktion |
| `error_type` | string \| null | Fehlerursache, wenn `status = failed` (z. B. `aml_risk`, siehe [References](/docs/references)) |
| `created_at` | string (ISO 8601) | Zeitpunkt, an dem die Auszahlung erstellt wurde |
| `updated_at` | string (ISO 8601) | Zeitpunkt der letzten Statusänderung |
| `from_currency` | string | Quell-Guthaben, von dem die Auszahlung bei automatischer Umrechnung abgebucht wurde (z. B. `USDT` für eine `BTC`-Auszahlung) |
| `debited_amount` | decimal | Vom `from_currency`-Guthaben abgebuchter Betrag |
| `debited_currency` | string | Währung der Abbuchung |
| `sign` | string (hex) | HMAC-SHA256-Signatur des payload, signiert mit dem **Payout-API-Schlüssel** |

## Best Practices

- **Idempotenz** — Prüfen Sie stets, ob die Zahlung bereits verarbeitet wurde (anhand von `order_id` oder `uuid`). Webhooks können mehrfach eintreffen.
- **Schnelle Antwort** — Antworten Sie so schnell wie möglich mit HTTP 200. Lagern Sie aufwändige Arbeit in eine Hintergrund-Queue aus.
- **Wiederholungen** — Erhält das System keine HTTP-200-Antwort, wird der Webhook nach 2 Minuten erneut gesendet. Maximal 5 Wiederholungsversuche.
- **Asynchrone Verarbeitung** — Verarbeiten Sie Webhook-Ereignisse asynchron, um die Antwort nicht zu blockieren.
- **Sicherheit** — Verifizieren Sie IMMER die `sign`-Signatur, bevor Sie dem payload vertrauen.

> **WARNING:** Webhooks können in beliebiger Reihenfolge eintreffen. Gehen Sie nicht davon aus, dass der erste empfangene Webhook der finale Zustand ist — rufen Sie bei Unsicherheit immer erneut über `/v1/payment/info` (oder `/v1/payout/status/{uuid}`) ab.

## Liefer- und Verarbeitungsvertrag

Verwenden Sie die folgende Reihenfolge in Ihrem webhook-Endpunkt:

1. Lesen Sie den Anforderungstext, ohne Geheimnisse oder die vollständige Signatur zu protokollieren.
2. Ermitteln Sie, ob es sich um ein Zahlungs-/statisches Wallet-Ereignis oder ein Auszahlungsevent handelt, um den richtigen API-Schlüssel auszuwählen.
3. Entfernen Sie `sign`, reproduzieren Sie die dokumentierten JSON-Bytes, berechnen Sie HMAC-SHA256 und vergleichen Sie im konstanten Zeitintervall.
4. Validieren Sie die erforderlichen Kennungen, Dezimalstrings und Statuswerte.
5. Fügen Sie atomar einen inbox/idempotency-Datensatz ein. Wenn er bereits existiert, geben Sie HTTP 200 zurück, ohne Nebeneffekte zu wiederholen.
6. Bestätigen Sie die Mutation der Bestellung/der Buchführung und stellen Sie nicht-kritische E-Mails, Analysen oder Benachrichtigungen in die Warteschlange.
7. Geben Sie HTTP 200 schnell zurück.

Rufen Sie keine langsamen Dienste von Drittanbietern auf, während Sie die idempotency-Transaktion halten. Ein timeout nach der Bestätigung, aber vor der Rückgabe, kann ein retry verursachen; das Duplikat muss den bestätigten inbox-Schlüssel beachten und ein No-Op werden.

### Empfohlene idempotency-Schlüssel

| Ereignis | Primäre Identität | Notizen |
|-------|------------------|-------|
| Zahlungssitzung | `uuid` + Status-/Versionsnachweis | Dasselbe invoice kann mehrere legitime Statusänderungen auslösen. |
| Teilzahlung-Aufladung | invoice `uuid` + `txid` | Mehr als eine Überweisung kann zum gleichen unterbezahlten invoice gehören. |
| Statisches Wallet-Einzahlung | Netzwerk + `txid` | `order_id` wird von jeder Einzahlung auf dieses Wallet wiederverwendet. |
| Auszahlung | Auszahlung `uuid` + Status | Erstelle niemals eine zweite Auszahlung aus webhook retry Logik. |

Wenn Ihr Schema keine Ereignis-ID hat, speichern Sie den verifizierten payload-Hash als zusätzliches Audit-Nachweis, ersetzen Sie jedoch nicht die oben genannten Geschäftsidentitäten durch einen Zeitstempel.

## Ordnung und reconciliation

Die Zustellung erfolgt mindestens einmal und Statusnachrichten können rasant sein. Implementiere monotone Geschäftsregeln statt "letzte Anfrage gewinnt":

- Verschiebe niemals eine erfüllte Bestellung zurück auf `check`, nur weil ein älteres Ereignis zu spät ankam;
- `underpaid_check` zusätzliche TXIDs zu erhalten, ohne frühere Credits zu wiederholen;
- `paid` und `overpaid` als erfolgreiche settlement-Staaten zu behandeln, während ihre unterschiedlichen Mengen erhalten bleiben;
- Behalte `underpaid` als ein endgültiges Teilzahlungsergebnis, es sei denn, die autoritative API meldet später einen anderen Zustand;
- Routen Sie `aml_lock` zur Überprüfung und lassen Sie nicht zu, dass ein generischer retry-Worker es ausfüllt;
- Abfrage von Zahlungs- oder Auszahlungsinformationen immer dann, wenn der Übergang unmöglich ist, der Kontext fehlt oder finanziell unklar ist.

Führe geplante reconciliation aus, selbst wenn webhook Lieferung gesund erscheint. Vergleichen Sie Ihren lokalen Terminalzustand und den gutgeschriebenen Betrag mit `/v1/payment/info`, `/v1/static-wallet/transactions` oder `/v1/payout/status/{uuid}` und warnen Sie auf Unterschiede, anstatt die Ledger-Geschichte stillschweigend zu überschreiben.

## Webhook Endpunktsicherheit

- HTTPS erfordern und den Rückruf öffentlich zugänglich halten; Private/Loopback-Rückrufziele werden während der Zahlungserstellung abgelehnt.
- Setzen Sie ein kleines Request-Body-Limit und einen JSON-Inhaltstyp ein.
- Rate-Limit vor teuren Arbeiten, aber genug Headroom für echte Bursts und retries lassen.
- Autorisieren Sie niemals einen webhook ausschließlich durch die Quell-IP. Netzwerk-Lowlists sind Verteidigung in Tiefe; Die HMAC-Verifizierung ist verpflichtend.
- Schwärbe `sign`, API-Schlüssel, Adressen, wenn es die Richtlinie ergibt, und persönliche Metadaten aus Anwendungsprotokollen.
- Halte sowohl aktuelle als auch explizit geplante Ersatztasten während eines kontrollierten Rotationsfensters verfügbar; Rate niemals, welcher Schlüssel ein Ereignis signiert hat.
- Geben Sie einen generischen Fehlerkörper bei ungültigen Signaturen zurück, damit der Endpunkt nicht zu einem Schlüssel- oder Kontoorakel wird.