Powiadomienia webhook
Otrzymuj w czasie rzeczywistym aktualizacje statusu płatności i wypłat poprzez webhooki podpisane HMAC.
System 2328.io wysyła webhook pod Twój url_callback przy każdej zmianie statusu płatności. To zalecany sposób otrzymywania powiadomień o udanych płatnościach.
Format żądania
- Method:
POST - Content-Type:
application/json - Signature: pole
signw treści żądania
Payload
Treść webhooka jest identyczna z odpowiedzią /v1/payment/info, wzbogaconą o pole sign używane do weryfikacji podpisu.
Płatność udana
{
"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",
"payment_amount": "0.95256917",
"merchant_amount": "0.949711462490000000",
"amount_usd": "2.41324380",
"exchange_rate": "0.01340691",
"sign": "6f8c15b6e53b506d5bfa38ed3fb3b50697af73434262153c02e412541372f04d"
}Płatność anulowana / nieudana
Gdy płatność nie jest w terminalnym stanie paid, pola txid, payment_amount oraz merchant_amount mają wartość 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,
"payment_amount": null,
"merchant_amount": null,
"amount_usd": "37.53934800",
"exchange_rate": "0.01340691",
"sign": "40ce68ad9691ad54e684329d75ab5adaf5b01409a2d18d3e0110b8c1be605342"
}Opis pól
| Pole | Typ | Opis |
|---|---|---|
uuid | string | UUID płatności |
order_id | string | Twój identyfikator zamówienia |
amount | decimal (8 dp) | Kwota fiat w currency |
currency | string | Waluta fiat zażądana przez sprzedawcę |
url | string | URL hostowanego checkoutu |
expires_at | string (ISO 8601) | Moment wygaśnięcia sesji płatności |
created_at | string (ISO 8601) | Moment utworzenia sesji płatności |
payer_currency | string | Kryptowaluta, w której płaci płacący |
payer_amount | decimal (8 dp) | Oczekiwana kwota w kryptowalucie |
network | string | Sieć blockchain |
address | string | Adres depozytowy |
payment_status | string | Jedna z wartości: pending, check, paid, underpaid_check, underpaid, overpaid, cancel, aml_lock (zobacz References) |
txid | string | null | Hash transakcji blockchain, obecny tylko po potwierdzonej płatności |
payment_amount | decimal | null | Faktycznie zapłacona kwota, obecna tylko po płatności |
merchant_amount | decimal (18 dp) | null | Kwota zaksięgowana sprzedawcy po opłatach |
amount_usd | decimal (8 dp) | Kwota w USD w chwili utworzenia |
exchange_rate | decimal | Użyty kurs wymiany krypto / fiat |
sign | string (hex) | Podpis HMAC-SHA256 payloadu |
Weryfikacja podpisu
Aby zweryfikować podpis webhooka:
- Wyciągnij pole
signz payloadu - Usuń pole
signz obiektu - Zakoduj pozostałe pola jako JSON
- Zakoduj JSON w base64
- Oblicz HMAC-SHA256 z ciągu base64 przy użyciu swojego API_KEY
- Porównaj obliczony podpis z wartością
sign, używając porównania w czasie stałym
<?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
endZawsze weryfikuj podpis przed zaksięgowaniem jakichkolwiek środków użytkownikowi. Niepodpisany lub błędnie podpisany webhook może być spreparowanym żądaniem.
Webhooki wypłat
Gdy zmienia się status wypłaty, system wysyła webhook POST pod adres url_callback przekazany w momencie utworzenia wypłaty. Jeśli url_callback nie został podany, dla tej wypłaty nie są wysyłane żadne webhooki.
Webhooki wypłat muszą być weryfikowane Twoim Payout API key — nie zwykłym kluczem API. Algorytm podpisywania jest identyczny jak dla webhooków płatności (usuń sign, zakoduj jako JSON, base64, HMAC-SHA256), różni się wyłącznie kluczem.
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",
"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"
}Opis pól
| Pole | Typ | Opis |
|---|---|---|
uuid | string | UUID wypłaty |
order_id | string | Twój identyfikator idempotentności / referencyjny, jeśli go podano |
status | string | pending, completed, failed, cancelled (zobacz References) |
currency | string | Waluta wypłaty |
network | string | Sieć blockchain |
amount | decimal | Kwota wypłaty (w currency) |
merchant_amount | decimal | Kwota pobrana z salda sprzedawcy |
network_amount | decimal | Kwota faktycznie wysłana w sieci |
amount_usd | decimal | Wartość w USD w chwili wypłaty |
to_address | string | Adres blockchain odbiorcy |
memo | string | null | Memo / tag docelowy, jeśli użyty |
txid | string | null | Hash transakcji blockchain, ustawiany przy completed |
block_number | integer | null | Wysokość bloku transakcji on-chain |
error_type | string | null | Powód, gdy status = failed (np. aml_risk, zobacz References) |
created_at | string (ISO 8601) | Moment utworzenia wypłaty |
updated_at | string (ISO 8601) | Moment ostatniej zmiany statusu |
from_currency | string | Saldo źródłowe, z którego pobrano wypłatę przy użyciu automatycznej konwersji (np. USDT dla wypłaty w BTC) |
debited_amount | decimal | Kwota pobrana z salda from_currency |
debited_currency | string | Waluta obciążenia |
sign | string (hex) | Podpis HMAC-SHA256 payloadu, podpisany Payout API key |
Dobre praktyki
- Idempotentność — zawsze sprawdzaj, czy płatność nie została już przetworzona (po
order_idlubuuid). Webhooki mogą docierać wielokrotnie. - Szybka odpowiedź — odpowiadaj HTTP 200 możliwie najszybciej. Cięższe operacje przekaż do kolejki w tle.
- Ponawianie prób — jeśli system nie otrzyma HTTP 200, webhook jest wysyłany ponownie po 2 minutach. Maksymalnie 5 prób ponawiania.
- Przetwarzanie asynchroniczne — obsługuj zdarzenia webhook asynchronicznie, aby nie blokować odpowiedzi.
- Bezpieczeństwo — ZAWSZE weryfikuj podpis
signprzed zaufaniem payloadowi.
Webhooki mogą docierać w innej kolejności niż zdarzenia. Nie zakładaj, że pierwszy otrzymany webhook to stan końcowy — jeśli potrzebujesz pewności, zawsze ponownie pobieraj dane przez /v1/payment/info (lub /v1/payout/status/{uuid}).