Webhook Notifications

Receive real-time payment and payout status updates via HMAC-signed webhooks.

The 2328.io system sends a webhook to your url_callback whenever a payment status changes. This is the recommended way to get notified about successful payments.

Request format

  • Method: POST
  • Content-Type: application/json
  • Signature: sign field in the request body

Payload

The payment webhook follows the /v1/payment/info response with description omitted, and includes payment_amount_usd, tx_explorer_url and the verification field sign. Static-wallet and payout webhooks also omit description.

Successful payment

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",
  "payment_amount_usd": "2.41324380",
  "merchant_amount": "0.949711462490000000",
  "confirmations": {
    "current": 20,
    "required": 20
  },
  "amount_usd": "2.41324380",
  "exchange_rate": "0.01340691",
  "sign": "6f8c15b6e53b506d5bfa38ed3fb3b50697af73434262153c02e412541372f04d"
}

Awaiting confirmation

Sent once, as soon as the payer's transaction is seen on-chain. The payment is not settled yet: txid is already known, while payment_amount, payment_amount_usd and merchant_amount are still null.

json
{
  "uuid": "7f6c1b6f-1c1a-4f2b-9a44-2a2f0d4a5b31",
  "order_id": "ORDER-12345",
  "amount": "2500.00000000",
  "currency": "RUB",
  "url": "https://go.2328.io/7f6c1b6f-1c1a-4f2b-9a44-2a2f0d4a5b31",
  "expires_at": "2026-05-09T16:56:58+03:00",
  "created_at": "2026-05-09T15:56:58+03:00",
  "payer_currency": "USDT",
  "payer_amount": "33.51727500",
  "network": "TRX-TRC20",
  "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
  "payment_status": "awaiting_confirmation",
  "txid": "9242e533703704ef3eaba840f70b4a26333e72c943377ee375fea17badb53def",
  "payment_amount": null,
  "payment_amount_usd": null,
  "merchant_amount": null,
  "confirmations": {
    "current": 3,
    "required": 20
  },
  "amount_usd": "33.51727500",
  "exchange_rate": "0.01340691",
  "sign": "0b1e5f9c7a3d2148c6e0b4f7d9a5c3e18f42b6d0a7c9e5314b8f2760d1a3c9e4"
}

An invoice in underpaid_check keeps that status and still receives this webhook when a top-up transaction appears on-chain.

Refund status updates

Invoice and static-wallet refunds use the same signed payment webhook payload. A webhook is emitted for every refund transition:

payment_statusMeaning
refund_processThe refund withdrawal was created and is being processed.
refund_paidThe refund withdrawal completed successfully.
refund_failThe refund withdrawal failed and the debited merchant funds were compensated.

Treat refund_process as non-terminal. Refund webhooks can be retried or arrive out of order, so store each transition idempotently using uuid plus payment_status and never replace refund_paid with a delayed processing or failure event.

Cancelled / failed payment

When a payment is cancelled or expires without an on-chain transaction, txid, payment_amount, payment_amount_usd, and merchant_amount are 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,
  "payment_amount_usd": null,
  "merchant_amount": null,
  "amount_usd": "37.53934800",
  "exchange_rate": "0.01340691",
  "sign": "40ce68ad9691ad54e684329d75ab5adaf5b01409a2d18d3e0110b8c1be605342"
}

Field reference

FieldTypeDescription
uuidstringPayment UUID
order_idstringYour order ID
amountdecimal (8 dp)Fiat amount in currency
currencystringFiat currency the merchant requested
urlstringHosted checkout URL
expires_atstring (ISO 8601)When the payment session expires
created_atstring (ISO 8601)When the payment session was created
payer_currencystringCrypto the payer is paying in
payer_amountdecimal (8 dp)Amount of crypto expected
networkstringBlockchain network
addressstringDeposit address
payment_statusstringOne of: pending, check, awaiting_confirmation, paid, underpaid_check, underpaid, overpaid, cancel, aml_lock, refund_process, refund_fail, refund_paid (see References)
txidstring | nullBlockchain tx hash, present once the payer's transaction is seen on-chain
payment_amountdecimal | nullActual paid amount, present only after payment
payment_amount_usdstring | nullUSD equivalent of payment_amount before fees, using the project’s exchange-rate source at webhook generation time. Decimal string with up to 8 fractional digits; null if the payment amount, currency or rate is unavailable.
merchant_amountdecimal (18 dp) | nullAmount credited to merchant after fees
tx_explorer_urlstring | nullBlockchain explorer transaction URL. null when txid is absent or the transfer is internal P2P.
confirmationsobject (optional)Confirmation progress: current is the observed count and required is the target. Omitted when unavailable.
amount_usddecimal (8 dp)Amount in USD at the time of creation
exchange_ratedecimalCrypto / fiat exchange rate used
signstring (hex)HMAC-SHA256 signature of the payload

For underpaid and overpaid, payment_amount_usd reflects the actual payment. amount and amount_usd still describe the invoice. This field is included in payment webhooks, not in the /v1/payment/info response.

Verifying the signature

To verify a webhook signature:

  1. Extract the sign field from the payload
  2. Remove the sign field from the object
  3. Encode the remaining fields as JSON
  4. Encode the JSON in Base64
  5. Compute HMAC-SHA256 from the Base64 string using your API_KEY
  6. Compare the computed signature with the sign value using a constant-time comparison
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);

Always verify the signature before crediting any funds to a user. An unsigned or incorrectly-signed webhook could be a spoofed request.

Payout webhooks

When a payout's status changes, the system sends a POST webhook to the url_callback URL passed when the payout was created. If url_callback was not provided, no webhooks are sent for that payout.

A payout emits a webhook when the transaction is broadcast (status: awaiting_confirmation, txid filled) and again when it becomes completed or failed.

Payout webhooks must be verified with your Payout API key — not the regular API key. The signing algorithm is identical to payment webhooks (strip sign, JSON-encode, base64, HMAC-SHA256), only the key differs.

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,
  "transfer_type": "blockchain",
  "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",
  "confirmations": {
    "current": 20,
    "required": 20
  },
  "from_currency": "USDT",
  "debited_amount": "1.050735",
  "debited_currency": "USDT",
  "sign": "925ad7bf3d6841864101f7cc2c7e30652e70a06cdb04dbe07a0129480000ce4a"
}

Field reference

FieldTypeDescription
uuidstringPayout UUID
order_idstringYour idempotency / reference ID, if you provided one
statusstringpending, awaiting_confirmation, completed, failed, cancelled (see References)
currencystringWithdrawal currency
networkstringBlockchain network
amountdecimalWithdrawal amount (in currency)
merchant_amountdecimalAmount charged from the merchant balance
confirmationsobject (optional)Confirmation progress: current is the observed count and required is the target. Omitted when unavailable.
network_amountdecimalAmount delivered to the recipient after fees, including internal P2P transfers.
amount_usddecimalUSD value at the time of the payout
to_addressstringRecipient blockchain address
memostring | nullMemo / destination tag, if used
transfer_typestring | nullinternal — settled inside 2328 without a blockchain transaction; blockchain — a blockchain transaction exists; null — no transaction has been recorded yet, so the transfer type is unknown.
txidstring | nullBlockchain transaction hash or the UUID of an internal P2P transfer. null until a transaction is recorded.
tx_explorer_urlstring | nullBlockchain explorer transaction URL. null when txid is absent or the transfer is internal P2P.
block_numberinteger | nullBlock height of the on-chain transaction
error_typestring | nullReason when status = failed (e.g. aml_risk, see References)
created_atstring (ISO 8601)When the payout was created
updated_atstring (ISO 8601)When the status last changed
from_currencystringSource balance the payout was debited from when auto-conversion was used (e.g. USDT for a BTC payout)
debited_amountdecimalAmount debited from from_currency balance
debited_currencystringCurrency of the debit
signstring (hex)HMAC-SHA256 signature of the payload, signed with the Payout API key

Internal transfers (P2P)

Selected fields of a payout completed inside 2328 without a blockchain transaction:

json
{
  "type": "payout",
  "transfer_type": "internal",
  "status": "completed",
  "txid": "12450ae7-6023-4248-980f-3f54ca7efc55",
  "tx_explorer_url": null,
  "block_number": null
}

Identify internal transfers by transfer_type, not by the shape of txid. For blockchain, check status to determine whether the payout is completed. transfer_type is included in the webhook signature: remove only sign when verifying it.

php
$isInternal = ($data['transfer_type'] ?? null) === 'internal';

Best practices

  • Idempotency — Always check if the payment has already been processed (by order_id or uuid). Webhooks may arrive multiple times.
  • Fast response — Return HTTP 200 as quickly as possible. Offload heavy work to a background queue.
  • Retries — If the system doesn't receive an HTTP 200, the webhook is resent after 2 minutes. Maximum 5 retry attempts.
  • Async processing — Handle webhook events asynchronously to avoid blocking the response.
  • Security — ALWAYS verify the sign signature before trusting the payload.

Webhooks can arrive out of order. Don't assume the first webhook you receive is the final state — always re-fetch via /v1/payment/info (or /v1/payout/status/{uuid}) if you need certainty.

Delivery and processing contract

Use the following order inside your webhook endpoint:

  1. Read the request body without logging secrets or the full signature.
  2. Identify whether it is a payment/static-wallet event or a payout event so you select the correct API key.
  3. Remove sign, reproduce the documented JSON bytes, calculate HMAC-SHA256, and compare in constant time.
  4. Validate required identifiers, decimal strings, and status values.
  5. Atomically insert an inbox/idempotency record. If it already exists, return HTTP 200 without repeating side effects.
  6. Commit the order/ledger mutation and enqueue non-critical email, analytics, or notifications.
  7. Return HTTP 200 quickly.

Do not call slow third-party services while holding the idempotency transaction. A timeout after you commit but before returning can cause a retry; the duplicate must observe the committed inbox key and become a no-op.

EventPrimary identityNotes
Payment sessionuuid + status/version evidenceThe same invoice can emit multiple legitimate status changes.
Partial-payment top-upinvoice uuid + txidMore than one transfer can belong to the same underpaid invoice.
Static-wallet depositnetwork + txidorder_id is reused by every deposit to that wallet.
Payment refund transitionpayment uuid + payment_statusrefund_process, refund_fail, and refund_paid are separate legitimate events.
Payoutpayout uuid + statusNever create a second payout from webhook retry logic.

If your schema has no event id, store the verified payload hash as additional audit evidence, but do not replace the business identities above with a timestamp.

Ordering and reconciliation

Delivery is at-least-once and status messages can race. Implement monotonic business rules rather than “last request wins”:

  • never move a fulfilled order back to check because an older event arrived late;
  • never roll a settled payment back to awaiting_confirmation when that event arrives late;
  • allow underpaid_check to receive additional txids without repeating earlier credits;
  • treat paid and overpaid as successful settlement states, while preserving their different amounts;
  • keep underpaid as a final partial-payment outcome unless the authoritative API later reports another state;
  • route aml_lock to review and do not let a generic retry worker fulfill it;
  • treat refund_process as non-terminal and refund_paid / refund_fail as terminal refund outcomes;
  • never regress refund_paid when an older refund webhook arrives late;
  • query payment/payout info whenever the transition is impossible, missing context, or financially ambiguous.

Run scheduled reconciliation even when webhook delivery appears healthy. Compare your local terminal state and credited amount with /v1/payment/info, /v1/static-wallet/transactions, or /v1/payout/status/{uuid} and alert on differences instead of silently overwriting ledger history.

Webhook endpoint security

  • Require HTTPS and keep the callback publicly reachable; private/loopback callback targets are rejected during payment creation.
  • Enforce a small request-body limit and JSON content type.
  • Rate-limit before expensive work, but leave enough headroom for legitimate bursts and retries.
  • Never authorize a webhook by source IP alone. Network allowlists are defense in depth; HMAC verification is mandatory.
  • Redact sign, API keys, addresses when required by policy, and personal metadata from application logs.
  • Keep both current and explicitly scheduled replacement keys available during a controlled rotation window; never guess which key signed an event.
  • Return a generic error body on invalid signatures so the endpoint does not become a key or account oracle.