Webhooks

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

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

فتح بصيغة Markdown
في هذه الصفحة
  1. اضبط نقطة استقبال
  2. كيف تبدو عملية التسليم
  3. أنواع الأحداث
  4. اختبار الـ webhook

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

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

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

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

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

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

  • metadata تُدمج مع ما تحمله نقطة الاستقبال: تُعيَّن المفاتيح التي ترسلها، وتبقى الأخرى، ويُحذف المفتاح المرسل بقيمة فارغة ("").
  • "disabled": true يوقف نقطة الاستقبال مؤقتًا: لا تُجرى إليها أي عملية تسليم جديدة، وتتوقف العمليات المنتظرة. و"disabled": false يعيد تفعيلها.
  • DELETE /v1/webhook_endpoints/{id} يحذفها نهائيًا. وتبقى عمليات تسليمها معروضة.
  • curl
  • Node.js
  • TypeScript
  • Python
  • PHP
  • Ruby
  • Go
  • Java
  • C#
  • Rust
endpoint.mjsendpoint.tsendpoint.pyendpoint.phpendpoint.rbendpoint.goEndpoint.javaEndpoint.csmain.rs
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": ["*"]
  }'
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)
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)
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
$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;
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']}"
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)
}
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());
    }
}
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);
// 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: التوقيع الذي تتحقق منه قبل أن تثق بنص الطلب.

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

أجب بأي حالة 2xx خلال 10 ثوانٍ. كل ما عدا ذلك، بما فيه إعادة التوجيه، يُعد فشلًا فتُعاد محاولة التسليم. نفّذ العمل البطيء (الرسائل الإلكترونية، الفواتير) بعد الإجابة.

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

  • إرسال حدث اختبار، في صفحة نقطة الاستقبال في لوحة التحكم (وضع الاختبار)، يسلّم حدثًا نموذجيًا للتأكد من أن عنوانك يجيب.
  • تُعرض كل عملية تسليم وكل محاولة من محاولاتها في لوحة التحكم، مع رمز الحالة، والمدة المستغرقة، ونص إجابتك. وإعادة الإرسال تضعها في قائمة الانتظار من جديد: تنطلق خلال ثوانٍ. ويجب أولًا تفعيل نقطة الاستقبال المعطّلة.
  • دفعة اختبار حقيقية، تُدفع باستخدام بطاقة اختبار أو عبر دفع نقدي محاكى، ترسل الأحداث الحقيقية.
  • لتجربة معالجك وحده، أرسل إليه طلبًا موقّعًا بنفسك: يشرح التحقق من التوقيعات الطريقة.