# إشعارات Webhook

> تلقَّ تحديثات حالة المدفوعات والسحوبات في الوقت الفعلي عبر webhooks موقّعة بـ HMAC.

يرسل نظام 2328.io webhook إلى `url_callback` الخاص بك كلما تغيرت حالة الدفع. هذه هي الطريقة الموصى بها للحصول على إشعار بالمدفوعات الناجحة.

## تنسيق الطلب

- **الطريقة:** `POST`
- **Content-Type:** `application/json`
- **التوقيع:** حقل `sign` في محتوى الطلب

## المحتوى

يتبع محتوى webhook تنسيق استجابة `/v1/payment/info` ويضيف `tx_explorer_url` إلى جانب حقل `sign` المستخدم للتحقق من التوقيع.

### دفعة ناجحة

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

### دفعة ملغاة / فاشلة

عندما لا تكون الدفعة في حالة `paid` نهائية، تكون قيم `txid`، `payment_amount`، و`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"
}
```

### مرجع الحقول

| الحقل | النوع | الوصف |
|-------|------|-------------|
| `uuid` | string | معرّف الدفعة UUID |
| `order_id` | string | معرّف الطلب الخاص بك |
| `amount` | decimal (8 dp) | المبلغ بالعملة الورقية في `currency` |
| `currency` | string | العملة الورقية التي طلبها التاجر |
| `url` | string | عنوان URL لصفحة الدفع المستضافة |
| `expires_at` | string (ISO 8601) | متى تنتهي صلاحية جلسة الدفع |
| `created_at` | string (ISO 8601) | متى تم إنشاء جلسة الدفع |
| `payer_currency` | string | العملة المشفرة التي يدفع بها الدافع |
| `payer_amount` | decimal (8 dp) | مبلغ العملة المشفرة المتوقع |
| `network` | string | شبكة البلوكتشين |
| `address` | string | عنوان الإيداع |
| `payment_status` | string | إحدى: `pending`، `check`، `paid`، `underpaid_check`، `underpaid`، `overpaid`، `cancel`، `aml_lock` (راجع [References](/docs/references)) |
| `txid` | string \| null | هاش معاملة البلوكتشين، يكون موجودًا فقط بعد تأكيد الدفع |
| `tx_explorer_url` | string \| null | رابط المعاملة في مستكشف البلوكشين. تكون القيمة `null` عند عدم وجود `txid` أو عندما يكون التحويل داخليًا بنظام P2P. |
| `payment_amount` | decimal \| null | المبلغ المدفوع فعليًا، يكون موجودًا فقط بعد الدفع |
| `merchant_amount` | decimal (18 dp) \| null | المبلغ المُضاف إلى التاجر بعد الرسوم |
| `amount_usd` | decimal (8 dp) | المبلغ بالدولار الأمريكي وقت الإنشاء |
| `exchange_rate` | decimal | سعر صرف العملة المشفرة / الورقية المستخدم |
| `sign` | string (hex) | توقيع HMAC-SHA256 للمحتوى |

## التحقق من التوقيع

للتحقق من توقيع webhook:

1. استخرج حقل `sign` من المحتوى
2. أزل حقل `sign` من الكائن
3. رمّز الحقول المتبقية كـ JSON
4. رمّز JSON بـ Base64
5. احسب HMAC-SHA256 من سلسلة Base64 باستخدام API_KEY الخاص بك
6. قارن التوقيع المحسوب بقيمة `sign` باستخدام مقارنة بزمن ثابت

#### 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:** **تحقق دائمًا من التوقيع** قبل إيداع أي أموال للمستخدم. webhook غير موقّع أو موقّع بشكل غير صحيح قد يكون طلبًا مزيفًا.

## webhooks السحب

عند تغيّر `status` لعملية سحب، يرسل النظام webhook بطريقة `POST` إلى عنوان `url_callback` الذي تم تمريره عند إنشاء السحب. إذا لم يتم توفير `url_callback`، فلن يتم إرسال أي webhooks لتلك العملية.

> **WARNING:** يجب التحقق من webhooks السحب باستخدام **Payout API key** الخاص بك — وليس API key العادي. خوارزمية التوقيع متطابقة مع webhooks المدفوعات (إزالة `sign`، ترميز JSON، base64، HMAC-SHA256)، يختلف فقط المفتاح.

### المحتوى

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

### مرجع الحقول

| الحقل | النوع | الوصف |
|-------|------|-------------|
| `uuid` | string | معرّف السحب UUID |
| `order_id` | string | معرّف idempotency / المرجع الخاص بك، إذا قمت بتوفيره |
| `status` | string | `pending`، `completed`، `failed`، `cancelled` (راجع [References](/docs/references)) |
| `currency` | string | عملة السحب |
| `network` | string | شبكة البلوكتشين |
| `amount` | decimal | مبلغ السحب (بـ `currency`) |
| `merchant_amount` | decimal | المبلغ المخصوم من رصيد التاجر |
| `network_amount` | decimal | المبلغ المُرسل فعليًا على البلوكتشين |
| `amount_usd` | decimal | القيمة بالدولار الأمريكي وقت السحب |
| `to_address` | string | عنوان البلوكتشين للمستلم |
| `memo` | string \| null | memo / تاج الوجهة، إن استُخدم |
| `txid` | string \| null | هاش معاملة البلوكتشين، يُحدد عند `completed` |
| `tx_explorer_url` | string \| null | رابط المعاملة في مستكشف البلوكشين. تكون القيمة `null` عند عدم وجود `txid` أو عندما يكون التحويل داخليًا بنظام P2P. |
| `block_number` | integer \| null | ارتفاع البلوك للمعاملة على البلوكتشين |
| `error_type` | string \| null | السبب عند `status = failed` (مثل `aml_risk`، راجع [References](/docs/references)) |
| `created_at` | string (ISO 8601) | متى تم إنشاء عملية السحب |
| `updated_at` | string (ISO 8601) | متى تغيرت الحالة آخر مرة |
| `from_currency` | string | الرصيد المصدري الذي خُصم منه السحب عند استخدام التحويل التلقائي (مثلًا `USDT` لسحب بـ `BTC`) |
| `debited_amount` | decimal | المبلغ المخصوم من رصيد `from_currency` |
| `debited_currency` | string | عملة الخصم |
| `sign` | string (hex) | توقيع HMAC-SHA256 للمحتوى، موقّع باستخدام **Payout API key** |

## أفضل الممارسات

- **Idempotency** — تحقق دائمًا مما إذا كانت الدفعة قد عولجت بالفعل (بواسطة `order_id` أو `uuid`). قد تصل webhooks عدة مرات.
- **استجابة سريعة** — أعد HTTP 200 بأسرع وقت ممكن. أنقل العمل الثقيل إلى طابور خلفي.
- **إعادة المحاولة** — إذا لم يتلقَّ النظام HTTP 200، تتم إعادة إرسال webhook بعد دقيقتين. الحد الأقصى 5 محاولات.
- **معالجة غير متزامنة** — تعامل مع أحداث webhook بشكل غير متزامن لتجنب حجب الاستجابة.
- **الأمان** — تحقق دائمًا من توقيع `sign` قبل الوثوق بالمحتوى.

> **WARNING:** قد تصل webhooks خارج الترتيب. لا تفترض أن أول webhook تستلمه هو الحالة النهائية — أعد الجلب دائمًا عبر `/v1/payment/info` (أو `/v1/payout/status/{uuid}`) إذا كنت بحاجة إلى يقين.

## عقد التسليم والمعالجة

استخدم الطلب التالي داخل نقطة نهاية الويب هوك الخاصة بك:

1. اقرأ محتوى الطلب دون تسجيل الأسرار أو التوقيع الكامل.
2. حدد ما إذا كان حدث دفع/محفظة ثابتة أو حدث سحب حتى تختار مفتاح API الصحيح.
3. قم بإزالة `sign`، وأعد إنتاج بايتات JSON الموثقة، واحسب HMAC-SHA256، وقارن في وقت ثابت.
4. تحقق من صحة المعرفات المطلوبة، والسلاسل العشرية، وقيم الحالة.
5. أدرج سجل صندوق البريد/التكرار بطريقة ذرية. إذا كان موجودًا بالفعل، أعد HTTP 200 دون تكرار التأثيرات الجانبية.
6. التزم بتغيير الطلب/دفتر الحسابات وضع البريد الإلكتروني غير الحرج أو التحليلات أو الإشعارات في قائمة الانتظار.
7. أرجع HTTP 200 بسرعة.

لا تستدعِ خدمات الطرف الثالث البطيئة أثناء الاحتفاظ بمعاملة التكرار. قد يؤدي انتهاء المهلة بعد الالتزام ولكن قبل الإرجاع إلى محاولة إعادة؛ يجب أن يلاحظ التكرار المفتاح المستلم الملتزم ويصبح بلا عملية.

### مفاتيح التكرار الموصى بها

| الحدث | الهوية الأساسية | ملاحظات |
|-------|------------------|-------|
| جلسة الدفع | `uuid` + دليل الحالة/الإصدار | يمكن للفاتورة نفسها إصدار تغييرات حالة مشروعة متعددة. |
| إعادة شحن بدفع جزئي | فاتورة `uuid` + `txid` | يمكن أن ينتمي أكثر من تحويل إلى نفس الفاتورة الناقصة الدفع. |
| إيداع المحفظة الثابتة | الشبكة + `txid` | `order_id` يُعاد استخدامه بواسطة كل إيداع لتلك المحفظة. |
| الدفع | دفع `uuid` + الحالة | لا تُنشئ دفعًا ثانيًا من منطق إعادة المحاولة عبر الويب هوك. |

إذا لم يكن لدى مخططك معرف حدث، خزّن تجزئة التحميل المؤكدة كدليل تدقيق إضافي، ولكن لا تستبدل الهويات التجارية أعلاه بالطابع الزمني.

## الترتيب والمطابقة

التسليم يتم على الأقل مرة واحدة ورسائل الحالة يمكن أن تأتي بتسلسل غير متوقع. نفذ قواعد أعمال أحادية الاتجاه بدلاً من "يفوز الطلب الأخير":

- لا تحرك الطلب المكتمل أبدًا إلى `check` لأن حدثًا أقدم وصل متأخرًا؛
- السماح لـ `underpaid_check` بتلقي معرفات معاملات إضافية دون تكرار الاعتمادات السابقة؛
- اعتبر `paid` و `overpaid` حالات تسوية ناجحة، مع الحفاظ على مبالغها المختلفة؛
- احتفظ بـ `underpaid` كنتيجة نهائية للمدفوعات الجزئية ما لم يبلغ API الموثوق عنه لاحقًا بحالة أخرى؛
- وجه `aml_lock` للمراجعة ولا تدع عامل إعادة المحاولة العامة ينفذه؛
- استعلام معلومات الدفع/السحب كلما تعذر الانتقال، أو كان السياق مفقودًا، أو غير واضح ماليًا.

قم بتشغيل التسوية المجدولة حتى عندما يبدو توصيل الويب هوك صحيًا. قارن بين حالة جهازك المحلي والمبلغ المعتمد مع `/v1/payment/info`، `/v1/static-wallet/transactions`، أو `/v1/payout/status/{uuid}` وابلغ عن الفروقات بدلاً من الكتابة الصامتة على سجل الدفعات.

## أمان نقطة نهاية الويب هوك

- تطلب HTTPS والحفاظ على إمكانية الوصول العام للنداء العكسي؛ تُرفض أهداف النداء العكسي الخاصة/المحلية أثناء إنشاء الدفع.
- فرض حد صغير لحجم جسم الطلب ونوع محتوى JSON.
- تحديد معدل قبل العمل المكلف، ولكن اترك مساحة كافية للزيادة المشروعة وإعادة المحاولة.
- لا تفوض أبدًا ويب هوك بواسطة عنوان IP المصدر فقط. قوائم السماح على الشبكة هي دفاع متعدد الطبقات؛ التحقق من HMAC إلزامي.
- قم بحجب `sign` ومفاتيح API والعناوين عند الحاجة طبقًا للسياسة، وبيانات التعريف الشخصية من سجلات التطبيق.
- احتفظ بكل من المفاتيح الحالية والمفاتيح البديلة المجدولة صراحة خلال نافذة التدوير المسيطر عليها؛ لا تحزر أبدًا أي مفتاح وقع الحدث.
- أعد جسم خطأ عام عند وجود توقيعات غير صالحة حتى لا تصبح نقطة النهاية مرجعًا للمفاتيح أو الحسابات.