# نظرة عامة على Webhooks

كيف يخبر AdProMaxPay خادمك بما حدث، وكيف تضبط نقطة استقبال، والأحداث التي يمكنك تلقيها.

الـ webhook طلب HTTPS يرسله AdProMaxPay إلى خادمك عندما يحدث شيء: نجاح دفعة،
أو انتهاء صلاحية رمز، أو اكتمال استرداد. يجيب خادمك بـ `2xx`، فتتم عملية التسليم.
بإشعارات الـ webhook تعرف نتيجة دفعة أجراها عميلك في مكان آخر، في الصفحة المستضافة
أو لدى وكيل دفع.

## اضبط نقطة استقبال

**نقطة الاستقبال** (endpoint) عنوان على خادمك، مع الأحداث التي يريدها. أنشئها من لوحة التحكم،
في **المطوّرون ← Webhooks**، أو عبر API.

* يجب أن يستخدم العنوان `https`، وألا يحمل اسم مستخدم ولا كلمة مرور، وأن يذكر مضيفًا يُحَل اسمه
  إلى عناوين عامة. يُرفض العنوان الخاص أو المحلي، واسم المضيف الذي لا يمكن حله،
  بالرمز `url_not_allowed`. ويجري الفحص نفسه من جديد قبل كل عملية تسليم. وللاختبار على
  حاسوبك، استخدم نفق HTTPS. في الأمثلة، يمثّل `shop.example.com` مضيفك أنت: كما
  هو مكتوب لا يمكن حل اسمه، فيُرفض.
* يسرد `enabled_events` أنواع الأحداث التي تريدها، أو `["*"]` لكلها.
* الاختبار والمباشر منفصلان: نقطة الاستقبال التي تُنشأ بمفتاح اختبار لا تتلقى إلا أحداث الاختبار. أنشئ
  نقطة الاستقبال للوضع المباشر عند انتقالك إليه.
* تحمل الاستجابة **سرّ التوقيع** الخاص بنقطة الاستقبال، `apx_whsec_…`، هذه المرة فقط. احفظه مع
  أسرارك الأخرى: تحتاج إليه [للتحقق من كل عملية تسليم](https://pay-docs.adpromax.com/ar/webhooks/signatures). ومن لوحة التحكم
  يمكنك عرضه من جديد أو تجديده.
* تملك المؤسسة **16 نقطة استقبال في كل وضع** على الأكثر. تُرفض أي نقطة إضافية بالرمز
  `webhook_endpoint_limit_reached`: احذف أولًا نقطة استقبال لم تعد تستخدمها.

### تغيير نقطة استقبال أو إيقافها أو حذفها

أرسل الحقول التي تتغير إلى `POST /v1/webhook_endpoints/{id}`؛ وتبقى الأخرى كما هي.

* `metadata` **تُدمج** مع ما تحمله نقطة الاستقبال: تُعيَّن المفاتيح التي ترسلها، وتبقى الأخرى،
  ويُحذف المفتاح المرسل بقيمة فارغة (`""`).
* `"disabled": true` يوقف نقطة الاستقبال مؤقتًا: لا تُجرى إليها أي عملية تسليم جديدة، وتتوقف العمليات المنتظرة.
  و`"disabled": false` يعيد تفعيلها.
* `DELETE /v1/webhook_endpoints/{id}` يحذفها نهائيًا. وتبقى عمليات تسليمها معروضة.

```bash
curl https://pay-api.adpromax.com/v1/webhook_endpoints \
  -u "$APX_SECRET_KEY:" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://shop.example.com/webhooks/payments",
    "enabled_events": ["*"]
  }'
```

```js title="endpoint.mjs"
const response = await fetch('https://pay-api.adpromax.com/v1/webhook_endpoints', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.APX_SECRET_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    url: 'https://shop.example.com/webhooks/payments',
    enabled_events: ['*'],
  }),
})
const endpoint = await response.json()
// The secret is shown this once: store it.
console.log(endpoint.id, endpoint.secret)
```

```ts title="endpoint.ts"
interface WebhookEndpoint {
  id: string
  object: 'webhook_endpoint'
  url: string
  enabled_events: string[]
  status: 'enabled' | 'disabled'
  livemode: boolean
  /** Only in the answer that created the endpoint. */
  secret?: string
}

interface ApiError {
  error: { type: string, code: string, message: string, request_id: string }
}

const response = await fetch('https://pay-api.adpromax.com/v1/webhook_endpoints', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.APX_SECRET_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    url: 'https://shop.example.com/webhooks/payments',
    enabled_events: ['*'],
  }),
})
if (!response.ok) {
  const { error } = (await response.json()) as ApiError
  throw new Error(`${error.code}: ${error.message}`)
}
const endpoint = (await response.json()) as WebhookEndpoint
// The secret is shown this once: store it.
console.log(endpoint.id, endpoint.secret)
```

```python title="endpoint.py"
import os

import requests

response = requests.post(
    "https://pay-api.adpromax.com/v1/webhook_endpoints",
    auth=(os.environ["APX_SECRET_KEY"], ""),
    json={
        "url": "https://shop.example.com/webhooks/payments",
        "enabled_events": ["*"],
    },
)
endpoint = response.json()
response.raise_for_status()
# The secret is shown this once: store it.
print(endpoint["id"], endpoint["secret"])
```

```php title="endpoint.php"
<?php
$ch = curl_init('https://pay-api.adpromax.com/v1/webhook_endpoints');
curl_setopt_array($ch, [
    CURLOPT_USERPWD => getenv('APX_SECRET_KEY') . ':',
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
    CURLOPT_POSTFIELDS => json_encode([
        'url' => 'https://shop.example.com/webhooks/payments',
        'enabled_events' => ['*'],
    ]),
    CURLOPT_RETURNTRANSFER => true,
]);
$endpoint = json_decode(curl_exec($ch), true);
// The secret is shown this once: store it.
echo $endpoint['id'], ' ', $endpoint['secret'], PHP_EOL;
```

```ruby title="endpoint.rb"
require 'json'
require 'net/http'

uri = URI('https://pay-api.adpromax.com/v1/webhook_endpoints')
request = Net::HTTP::Post.new(uri)
request.basic_auth(ENV.fetch('APX_SECRET_KEY'), '')
request['Content-Type'] = 'application/json'
request.body = JSON.generate(
  url: 'https://shop.example.com/webhooks/payments',
  enabled_events: ['*']
)

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end
endpoint = JSON.parse(response.body)
raise endpoint.dig('error', 'message') unless response.is_a?(Net::HTTPSuccess)

# The secret is shown this once: store it.
puts "#{endpoint['id']} #{endpoint['secret']}"
```

```go title="endpoint.go"
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"os"
)

type WebhookEndpoint struct {
	ID            string   `json:"id"`
	URL           string   `json:"url"`
	EnabledEvents []string `json:"enabled_events"`
	Status        string   `json:"status"`
	Secret        string   `json:"secret"`
}

type APIError struct {
	Error struct {
		Code    string `json:"code"`
		Message string `json:"message"`
	} `json:"error"`
}

func main() {
	body, err := json.Marshal(map[string]any{
		"url":            "https://shop.example.com/webhooks/payments",
		"enabled_events": []string{"*"},
	})
	if err != nil {
		log.Fatal(err)
	}
	req, err := http.NewRequest(http.MethodPost,
		"https://pay-api.adpromax.com/v1/webhook_endpoints", bytes.NewReader(body))
	if err != nil {
		log.Fatal(err)
	}
	req.SetBasicAuth(os.Getenv("APX_SECRET_KEY"), "")
	req.Header.Set("Content-Type", "application/json")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer res.Body.Close()

	if res.StatusCode >= 400 {
		var apiErr APIError
		json.NewDecoder(res.Body).Decode(&apiErr)
		log.Fatal(apiErr.Error.Message)
	}
	var endpoint WebhookEndpoint
	if err := json.NewDecoder(res.Body).Decode(&endpoint); err != nil {
		log.Fatal(err)
	}
	// The secret is shown this once: store it.
	fmt.Println(endpoint.ID, endpoint.Secret)
}
```

```java title="Endpoint.java"
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class Endpoint {
    public static void main(String[] args) throws Exception {
        String body = """
            {
              "url": "https://shop.example.com/webhooks/payments",
              "enabled_events": ["*"]
            }
            """;
        HttpRequest request = HttpRequest.newBuilder(
                URI.create("https://pay-api.adpromax.com/v1/webhook_endpoints"))
            .header("Authorization", "Bearer " + System.getenv("APX_SECRET_KEY"))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(body))
            .build();
        HttpResponse<String> response = HttpClient.newHttpClient()
            .send(request, HttpResponse.BodyHandlers.ofString());
        if (response.statusCode() >= 400) {
            throw new IllegalStateException(response.body());
        }
        // The secret is shown this once: store it.
        System.out.println(response.statusCode() + " " + response.body());
    }
}
```

```csharp title="Endpoint.cs"
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;

var json = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("APX_SECRET_KEY"));

using var response = await http.PostAsJsonAsync(
    "https://pay-api.adpromax.com/v1/webhook_endpoints",
    new
    {
        url = "https://shop.example.com/webhooks/payments",
        enabled_events = new[] { "*" },
    });
if (!response.IsSuccessStatusCode)
{
    var failure = await response.Content.ReadFromJsonAsync<ApiError>(json);
    throw new InvalidOperationException(failure?.Error.Message);
}
var endpoint = await response.Content.ReadFromJsonAsync<WebhookEndpoint>(json);
// The secret is shown this once: store it.
Console.WriteLine($"{endpoint!.Id} {endpoint.Secret}");

record WebhookEndpoint(
    string Id, string Url, string[] EnabledEvents, string Status, string? Secret);
record ApiError(ApiErrorBody Error);
record ApiErrorBody(string Code, string Message);
```

```rust title="main.rs"
// Cargo.toml: reqwest = { version = "0.12", features = ["blocking", "json"] }
//             serde = { version = "1", features = ["derive"] }
//             serde_json = "1"
use serde::Deserialize;
use serde_json::json;

#[derive(Deserialize)]
struct WebhookEndpoint {
    id: String,
    /// Only in the answer that created the endpoint.
    secret: Option<String>,
}

#[derive(Deserialize)]
struct ApiError {
    error: ErrorBody,
}

#[derive(Deserialize)]
struct ErrorBody {
    message: String,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let response = reqwest::blocking::Client::new()
        .post("https://pay-api.adpromax.com/v1/webhook_endpoints")
        .bearer_auth(std::env::var("APX_SECRET_KEY")?)
        .json(&json!({
            "url": "https://shop.example.com/webhooks/payments",
            "enabled_events": ["*"]
        }))
        .send()?;
    if !response.status().is_success() {
        let failure: ApiError = response.json()?;
        return Err(failure.error.message.into());
    }
    let endpoint: WebhookEndpoint = response.json()?;
    // The secret is shown this once: store it.
    println!("{} {}", endpoint.id, endpoint.secret.unwrap_or_default());
    Ok(())
}
```

## كيف تبدو عملية التسليم

كل عملية تسليم طلب `POST` نصّه JSON، وهو **الحدث**، مع هذه الترويسات:

* `Content-Type: application/json`؛
* `X-AdProMaxPay-Signature`: [التوقيع](https://pay-docs.adpromax.com/ar/webhooks/signatures) الذي تتحقق منه قبل أن تثق بنص الطلب.

يذكر الحدث نوعه في `type` ويحمل، في `data.object`، الكائن كما كان عند وقوع
الحدث: هنا، نية الدفع (payment intent) التي نجحت. استخدم `id` لتتعرف على حدث سبق أن
عالجته. ويذكر `request` طلب API الذي سبّب الحدث؛ ويكون فارغًا عندما تسبّبه دفعة
عميلك، كما هنا.

أجب بأي حالة `2xx` خلال **10 ثوانٍ**. كل ما عدا ذلك، بما فيه إعادة التوجيه، يُعد
فشلًا [فتُعاد محاولة](https://pay-docs.adpromax.com/ar/webhooks/retries) التسليم. نفّذ العمل البطيء (الرسائل الإلكترونية، الفواتير) بعد
الإجابة.

```json title="payment_intent.succeeded"
{
  "id": "evt_01k7d6b4c9x3z7v2n5m8q1w4ry",
  "object": "event",
  "type": "payment_intent.succeeded",
  "api_version": "2026-11-01",
  "created": 1790851200,
  "livemode": false,
  "organization": "org_01k7cz9q3m8v6t2x4b5n7p1r0s",
  "data": {
    "object": {
      "id": "pi_01k7d2h6w0f3r8t5y2v9n4p6qa",
      "object": "payment_intent",
      "amount": 25000,
      "amount_received": 25000,
      "currency": "mad",
      "status": "succeeded",
      "latest_charge": "ch_01k7d2j8m3n6b9v2c5x8z1a4qd",
      "metadata": {},
      "livemode": false
    }
  },
  "request": { "id": null, "idempotency_key": null },
  "pending_webhooks": 1
}
```

## أنواع الأحداث

| الحدث                            | متى                                                                           |
| -------------------------------- | ----------------------------------------------------------------------------- |
| `payment_intent.created`         | أُنشئت نية دفع.                                                               |
| `payment_intent.requires_action` | فُتحت صفحة دفع بالبطاقة أو أُصدر رمز دفع نقدي.                                |
| `payment_intent.processing`      | دفع عميلك؛ والتأكيد في الطريق.                                                |
| `payment_intent.succeeded`       | تم تأكيد الدفعة: سلّم الطلبية.                                                |
| `payment_intent.payment_failed`  | فشلت محاولة: رفضها البنك، أو انتهت صلاحيتها، أو رفضتها شبكة الدفع.            |
| `payment_intent.canceled`        | أُلغيت نية الدفع.                                                             |
| `charge.succeeded`               | نجحت محاولة.                                                                  |
| `charge.failed`                  | فشلت محاولة.                                                                  |
| `charge.refunded`                | استُردّت محاولة، كليًا أو جزئيًا.                                             |
| `checkout.session.completed`     | دُفعت جلسة دفع.                                                               |
| `checkout.session.expired`       | انتهت جلسة دفع دون دفع.                                                       |
| `refund.created`                 | أُنشئت عملية استرداد.                                                         |
| `refund.updated`                 | تغيّرت حالة عملية استرداد، مثلًا إلى `succeeded`.                             |
| `refund.failed`                  | فشلت عملية استرداد.                                                           |
| `review.opened`                  | لم يتطابق شيء (مبلغ آخر، أو مال وصل بعد النهاية)؛ ولم يُضف أي مبلغ إلى رصيدك. |
| `review.closed`                  | حُسمت مراجعة.                                                                 |

يضم الفهرس أيضًا `payout.created` و`payout.paid` و`payout.failed` و`balance.available` و
`account.updated`. يمكن لنقطة استقبال الاشتراك فيها من الآن؛ وتُرسل عندما تصل التسويات والأرصدة
إلى API.

## اختبار الـ webhook

* **إرسال حدث اختبار**، في صفحة نقطة الاستقبال في لوحة التحكم (وضع الاختبار)، يسلّم حدثًا نموذجيًا
  للتأكد من أن عنوانك يجيب.
* تُعرض كل عملية تسليم وكل محاولة من محاولاتها في لوحة التحكم، مع رمز الحالة،
  والمدة المستغرقة، ونص إجابتك. و**إعادة الإرسال** تضعها في قائمة الانتظار من جديد: تنطلق خلال ثوانٍ.
  ويجب أولًا تفعيل نقطة الاستقبال المعطّلة.
* دفعة اختبار حقيقية، تُدفع باستخدام [بطاقة اختبار](https://pay-docs.adpromax.com/ar/sandbox/test-cards) أو عبر
  [دفع نقدي محاكى](https://pay-docs.adpromax.com/ar/sandbox/cash-scenarios)، ترسل الأحداث الحقيقية.
* لتجربة معالجك وحده، أرسل إليه طلبًا موقّعًا بنفسك: يشرح
  [التحقق من التوقيعات](https://pay-docs.adpromax.com/ar/webhooks/signatures#اختبر-معالجك) الطريقة.
