# Webhook-meldingen

> Ontvang realtime statusupdates van betalingen en uitbetalingen via HMAC-ondertekende webhooks.

Het 2328.io-systeem stuurt een webhook naar je `url_callback` zodra de status van een betaling verandert. Dit is de aanbevolen manier om op de hoogte te worden gebracht van succesvolle betalingen.

## Verzoekformaat

- **Methode:** `POST`
- **Content-Type:** `application/json`
- **Handtekening:** `sign`-veld in de body van het verzoek

## Payload

De webhook-body volgt het formaat van de response van `/v1/payment/info` en voegt `tx_explorer_url` en het `sign`-veld voor handtekeningverificatie toe.

### Succesvolle betaling

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

### Geannuleerde / mislukte betaling

Wanneer de betaling niet in een terminale `paid`-status staat, zijn `txid`, `payment_amount` en `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"
}
```

### Veldreferentie

| Veld | Type | Beschrijving |
|------|------|--------------|
| `uuid` | string | Payment UUID |
| `order_id` | string | Je order-ID |
| `amount` | decimal (8 dp) | Fiatbedrag in `currency` |
| `currency` | string | Fiatvaluta die de merchant heeft aangevraagd |
| `url` | string | URL van de gehoste checkout |
| `expires_at` | string (ISO 8601) | Wanneer de betalingssessie verloopt |
| `created_at` | string (ISO 8601) | Wanneer de betalingssessie is aangemaakt |
| `payer_currency` | string | Crypto waarin de betaler betaalt |
| `payer_amount` | decimal (8 dp) | Verwacht cryptobedrag |
| `network` | string | Blockchain-netwerk |
| `address` | string | Stortingsadres |
| `payment_status` | string | Een van: `pending`, `check`, `paid`, `underpaid_check`, `underpaid`, `overpaid`, `cancel`, `aml_lock` (zie [References](/docs/references)) |
| `txid` | string \| null | Hash van blockchain-transactie, alleen aanwezig na een bevestigde betaling |
| `tx_explorer_url` | string \| null | URL van de transactie in de blockchainverkenner. `null` als `txid` ontbreekt of de overdracht intern P2P is. |
| `payment_amount` | decimal \| null | Werkelijk betaald bedrag, alleen aanwezig na betaling |
| `merchant_amount` | decimal (18 dp) \| null | Bedrag bijgeboekt aan merchant na fees |
| `amount_usd` | decimal (8 dp) | Bedrag in USD op moment van aanmaken |
| `exchange_rate` | decimal | Gebruikte crypto/fiat-wisselkoers |
| `sign` | string (hex) | HMAC-SHA256-handtekening van de payload |

## De handtekening verifiëren

Om een webhook-handtekening te verifiëren:

1. Haal het `sign`-veld uit de payload
2. Verwijder het `sign`-veld uit het object
3. Encodeer de overige velden als JSON
4. Encodeer de JSON in base64
5. Bereken HMAC-SHA256 over de base64-string met je API_KEY
6. Vergelijk de berekende handtekening met de waarde van `sign` met behulp van een constant-time vergelijking

#### 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:** **Verifieer altijd de handtekening** voordat je geld aan een gebruiker bijboekt. Een niet-ondertekende of onjuist ondertekende webhook kan een vervalst verzoek zijn.

## Uitbetalingswebhooks

Wanneer de `status` van een uitbetaling verandert, stuurt het systeem een `POST`-webhook naar de `url_callback`-URL die bij het aanmaken van de uitbetaling is meegegeven. Als `url_callback` niet is opgegeven, worden er geen webhooks voor die uitbetaling verstuurd.

> **WARNING:** Uitbetalingswebhooks moeten worden geverifieerd met je **Payout API key** — niet met de gewone API key. Het ondertekeningsalgoritme is identiek aan dat van betalingswebhooks (verwijder `sign`, encodeer als JSON, base64, HMAC-SHA256), alleen de key verschilt.

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

### Veldreferentie

| Veld | Type | Beschrijving |
|------|------|--------------|
| `uuid` | string | Payout UUID |
| `order_id` | string | Je idempotentie-/referentie-ID, indien meegegeven |
| `status` | string | `pending`, `completed`, `failed`, `cancelled` (zie [References](/docs/references)) |
| `currency` | string | Uitbetalingsvaluta |
| `network` | string | Blockchain-netwerk |
| `amount` | decimal | Uitbetalingsbedrag (in `currency`) |
| `merchant_amount` | decimal | Bedrag dat van het merchantsaldo is afgeschreven |
| `network_amount` | decimal | Bedrag dat daadwerkelijk on-chain is verstuurd |
| `amount_usd` | decimal | USD-waarde op het moment van uitbetaling |
| `to_address` | string | Blockchain-adres van de ontvanger |
| `memo` | string \| null | Memo / destination tag, indien gebruikt |
| `txid` | string \| null | Hash van blockchain-transactie, ingesteld bij `completed` |
| `tx_explorer_url` | string \| null | URL van de transactie in de blockchainverkenner. `null` als `txid` ontbreekt of de overdracht intern P2P is. |
| `block_number` | integer \| null | Blockhoogte van de on-chain transactie |
| `error_type` | string \| null | Reden bij `status = failed` (bijv. `aml_risk`, zie [References](/docs/references)) |
| `created_at` | string (ISO 8601) | Wanneer de uitbetaling is aangemaakt |
| `updated_at` | string (ISO 8601) | Wanneer de status voor het laatst is gewijzigd |
| `from_currency` | string | Bronsaldo waarvan de uitbetaling is gedebiteerd wanneer automatische conversie is gebruikt (bijv. `USDT` voor een uitbetaling in `BTC`) |
| `debited_amount` | decimal | Bedrag dat van het `from_currency`-saldo is afgeschreven |
| `debited_currency` | string | Valuta van de afschrijving |
| `sign` | string (hex) | HMAC-SHA256-handtekening van de payload, ondertekend met de **Payout API key** |

## Best practices

- **Idempotentie** — Controleer altijd of de betaling al is verwerkt (op `order_id` of `uuid`). Webhooks kunnen meerdere keren binnenkomen.
- **Snelle response** — Geef zo snel mogelijk HTTP 200 terug. Verplaats zwaar werk naar een achtergrondqueue.
- **Retries** — Als het systeem geen HTTP 200 ontvangt, wordt de webhook na 2 minuten opnieuw verstuurd. Maximaal 5 retry-pogingen.
- **Asynchrone verwerking** — Verwerk webhook-events asynchroon om te voorkomen dat de response wordt geblokkeerd.
- **Beveiliging** — Verifieer ALTIJD de `sign`-handtekening voordat je de payload vertrouwt.

> **WARNING:** Webhooks kunnen in een willekeurige volgorde binnenkomen. Ga er niet vanuit dat de eerste webhook die je ontvangt de eindtoestand is — haal indien zekerheid nodig is altijd opnieuw de status op via `/v1/payment/info` (of `/v1/payout/status/{uuid}`).

## Leverings- en verwerkingscontract

Gebruik de volgende volgorde binnen uw webhook-endpoint:

1. Lees de request body zonder geheimen of de volledige handtekening te loggen.
2. Identificeer of het een betaling/static-wallet gebeurtenis is of een uitbetalingsgebeurtenis, zodat u de juiste API-sleutel selecteert.
3. Verwijder `sign`, reproduceer de gedocumenteerde JSON-bytes, bereken HMAC-SHA256 en vergelijk in constante tijd.
4. Valideer de vereiste identificatoren, decimale strings en statuswaarden.
5. Voeg atomisch een inbox/idempotentie-record in. Als deze al bestaat, retourneer HTTP 200 zonder bijwerkingen te herhalen.
6. Voer de order-/grootboekmutatie uit en plaats niet-kritieke e-mails, analyses of meldingen in de wachtrij.
7. Geef snel HTTP 200 terug.

Roep geen trage externe diensten aan terwijl de idempotentie-transactie wordt vastgehouden. Een time-out na commitment maar vóór terugkeer kan een herhaling veroorzaken; de duplicaat moet de vastgelegde inbox-sleutel observeren en een no-op worden.

### Aanbevolen idempotentie-sleutels

| Gebeurtenis | Primaire identiteit | Notities |
|-------|------------------|-------|
| Betaalsessie | `uuid` + status-/versiebewijs | Dezelfde factuur kan meerdere legitieme statuswijzigingen doorvoeren. |
| Deels-betaling opwaardering | factuur `uuid` + `txid` | Meer dan één overboeking kan bij dezelfde onderbetaalde factuur horen. |
| Statische-portemonnee storting | netwerk + `txid` | `order_id` wordt hergebruikt door elke storting naar die portemonnee. |
| Uitbetaling | uitbetaling `uuid` + status | Maak nooit een tweede uitbetaling aan vanuit webhook-herhaal logica. |

Als je schema geen gebeurtenis-id heeft, sla de geverifieerde payload-hash op als aanvullend auditbewijs, maar vervang de bovenstaande bedrijfsidentiteiten niet door een tijdstempel.

## Bestelling en afstemming

De levering is minstens één keer en statusberichten kunnen elkaar inhalen. Implementeer monotoon zakelijke regels in plaats van 'laatste verzoek wint':

- verplaats een vervulde bestelling nooit terug naar `check` omdat een ouder evenement te laat is aangekomen;
- sta `underpaid_check` toe om aanvullende txids te ontvangen zonder eerdere kredieten te herhalen;
- behandel `paid` en `overpaid` als succesvolle afwikkelingstoestanden, terwijl je hun verschillende bedragen behoudt;
- houd `underpaid` als een definitief gedeeltelijk betalingsresultaat tenzij de gezaghebbende API later een andere staat meldt;
- route `aml_lock` naar beoordeling en laat een generieke herhalingswerker dit niet vervullen;
- query betalings-/uitbetalingsinformatie telkens wanneer de overgang onmogelijk is, context ontbreekt, of financieel ambigu is.

Voer geplande reconciliatie uit, zelfs wanneer de levering van webhooks gezond lijkt. Vergelijk je lokale terminalstatus en gecrediteerde bedrag met `/v1/payment/info`, `/v1/static-wallet/transactions` of `/v1/payout/status/{uuid}` en waarschuw bij verschillen in plaats van stilzwijgend de grootboekgeschiedenis te overschrijven.

## Beveiliging van webhook-endpoint

- Vereis HTTPS en houd de callback openbaar bereikbaar; privé-/loopback-callbackdoelen worden afgewezen tijdens het maken van betalingen.
- Handhaaf een kleine limiet voor request-body en JSON-inhoudstype.
- Beperk het aantal aanvragen voordat er kostbaar werk wordt uitgevoerd, maar laat genoeg speelruimte voor legitieme pieken en herhaalde pogingen.
- Autoriseer nooit een webhook alleen op basis van het bron-IP. Netwerk-toegestane lijsten zijn een verdediging in diepte; HMAC-verificatie is verplicht.
- Redigeer `sign`, API-sleutels, adressen wanneer dit vereist is door het beleid, en persoonlijke metadata uit applicatielogs.
- Houd zowel de huidige als expliciet geplande vervangende sleutels beschikbaar tijdens een gecontroleerd rotatievenster; raad nooit welke sleutel een gebeurtenis heeft ondertekend.
- Geef een algemene foutmelding terug bij ongeldige handtekeningen zodat het eindpunt geen sleutel- of accountoracle wordt.