Files
boursecu_server/docs/EXTERNAL_PAYMENT_PARTNER.md

15 KiB
Raw Permalink Blame History

راهنمای اتصال پرداخت خارجی (Partner Integration)

این سند برای تیم فنی سایت‌هایی است که می‌خواهند کاربر را به Montify بفرستند، پرداخت را از درگاه انجام دهند، و نتیجه را امن دریافت کنند.


۱. خلاصه جریان

سایت شما (سرور)
   │  POST /external-payment/initiate  (امضای HMAC)
   ▼
Montify → payment_url
   │
   ▼  redirect کاربر
/pay/external/{token}  →  درگاه پرداخت
   │
   ▼  برگشت از درگاه
Montify verify
   │
   ├─► redirect مرورگر به return_url شما (با signature)
   └─► POST webhook به سرور شما (با signature)

نکات مهم:

  • کاربر نهایی نیاز به لاگین در Montify ندارد.
  • مبلغ و آدرس برگشت فقط از سرور شما (با امضا) پذیرفته می‌شود؛ لینک دستی/جعلی کار نمی‌کند.
  • client_secret فقط روی سرور شما بماند؛ هرگز در فرانت‌اند قرار ندهید.

۲. پیش‌نیازها

از ادمین Montify این اطلاعات را دریافت کنید:

مقدار توضیح
BASE_URL آدرس API نود، مثلاً https://node.example.com
APP_URL آدرس فرانت Montify (برای payment_url)
client_id شناسه partner شما
client_secret رمز مخفی (فقط یک‌بار نمایش داده می‌شود)

همچنین این موارد باید در پنل partner ثبت شده باشد:

  • allowed_return_urls — پیشوند آدرس‌های مجاز برگشت (مثلاً https://yoursite.com/payment/)
  • webhook_url — اختیاری؛ آدرس پیش‌فرض webhook شما
  • allowed_ips — اختیاری؛ اگر پر باشد فقط همان IPها مجازند

۳. احراز هویت درخواست‌های سرور به سرور

برای initiate و status این هدرها الزامی است:

Header توضیح
X-Client-Id همان client_id
X-Timestamp Unix time به ثانیه
X-Nonce یکتا برای هر درخواست (مثلاً UUID)
X-Signature HMAC-SHA256 روی canonical string
Content-Type application/json

۳.۱. ساخت Canonical String

METHOD
PATH
BODY_HASH
TIMESTAMP
NONCE

با \n بین خطوط. بدون \n انتهایی.

  • METHOD: حروف بزرگ (POST یا GET)
  • PATH: فقط path بدون query، مثلاً /external-payment/initiate
  • BODY_HASH: SHA256 از stableStringify(body) به صورت hex
    برای GET بدون body: hash روی رشته خالی ""
  • TIMESTAMP و nonce: همان مقادیر هدر

۳.۲. stableStringify

قبل از hash کردن body، JSON را با کلیدهای مرتب‌شده بسازید:

  • object: کلیدها به ترتیب الفبایی
  • array: ترتیب حفظ شود
  • null / number / string / boolean: مثل JSON.stringify

مثال:

{"amount":1000,"gateway":"zarinpall","reference_id":"ORD-1","return_url":"https://yoursite.com/ok"}

نه:

{"return_url":"...","amount":1000,...}

۳.۳. Signature

X-Signature = HMAC_SHA256(canonical, client_secret)  → hex

۳.۴. محدودیت‌ها

  • اختلاف ساعت مجاز: ±۵ دقیقه
  • هر nonce فقط یک‌بار قابل استفاده است (ضد replay)

۴. APIها

۴.۱. شروع پرداخت — POST /external-payment/initiate

Auth: HMAC (الزامی)

Body:

فیلد نوع الزامی توضیح
amount number بله مبلغ به ریال (حداقل ۱۰۰۰)
reference_id string بله شناسه سفارش شما (یکتا در وضعیت فعال)
gateway string بله یکی از: zarinpall ، zibal ، payping ، sepal
return_url string بله باید با یکی از allowed_return_urls شروع شود
webhook_url string خیر اگر نباشد از webhook پیش‌فرض partner استفاده می‌شود
description string خیر توضیح
metadata object خیر داده دلخواه شما

نمونه درخواست:

POST /external-payment/initiate
Content-Type: application/json
X-Client-Id: ptr_xxxxxxxx
X-Timestamp: 1720000000
X-Nonce: 550e8400-e29b-41d4-a716-446655440000
X-Signature: <hex>

{
  "amount": 500000,
  "reference_id": "ORDER-1001",
  "gateway": "zarinpall",
  "return_url": "https://yoursite.com/payment/result",
  "webhook_url": "https://yoursite.com/api/payment/webhook",
  "description": "پرداخت سفارش ۱۰۰۱"
}

پاسخ موفق 201:

{
  "session_id": "a1b2c3...",
  "payment_url": "https://shop.example.com/pay/external/a1b2c3...",
  "expires_at": "2026-07-21T10:15:00.000Z",
  "reference_id": "ORDER-1001"
}

کاربر را به payment_url هدایت کنید. جلسه حدود ۱۵ دقیقه اعتبار دارد.

خطاهای رایج:

error معنی
invalid_signature امضا اشتباه است
expired_timestamp ساعت سرور نامعتبر
replay_detected nonce تکراری
invalid_return_url return_url در whitelist نیست
invalid_gateway درگاه نامعتبر
amount_too_low مبلغ کمتر از ۱۰۰۰ ریال
duplicate_reference reference_id قبلاً در وضعیت فعال استفاده شده
gateway_not_configured درگاه در Montify پیکربندی نشده

۴.۲. استعلام وضعیت — GET /external-payment/status

Auth: HMAC (الزامی)

یکی از queryها لازم است:

  • ?reference_id=ORDER-1001
  • ?session_id=<session_id>

پاسخ نمونه:

{
  "token": "a1b2c3...",
  "reference_id": "ORDER-1001",
  "amount": 500000,
  "gateway": "zarinpall",
  "status": "paid",
  "track_number": "123456789",
  "expires_at": "...",
  "paid_at": "...",
  "created_at": "..."
}

وضعیت‌ها (status):

status معنی
pending ساخته شده، هنوز به درگاه نرفته
processing به درگاه هدایت شده
paid پرداخت موفق
failed ناموفق
expired منقضی
cancelled لغو

منبع حقیقت نهایی برای علامت‌گذاری سفارش: Webhook یا استعلام status. فقط به redirect مرورگر اکتفا نکنید.


۵. برگشت کاربر (Browser Redirect)

بعد از پرداخت، Montify کاربر را به return_url شما می‌فرستد با queryهای امضاشده:

https://yoursite.com/payment/result?
  status=success
  &reference_id=ORDER-1001
  &session_id=a1b2c3...
  &amount=500000
  &track_number=123456789
  &timestamp=1720000123
  &signature=<hex>
پارامتر توضیح
status success یا failed
reference_id همان شناسه شما
session_id توکن جلسه
amount مبلغ (ریال)
track_number کد پیگیری درگاه (ممکن است خالی باشد)
timestamp زمان امضا
signature HMAC روی payload

تأیید امضای redirect

  1. همه پارامترها به‌جز signature را بگیرید.
  2. یک object بسازید (مقادیر string هستند).
  3. stableStringify کنید.
  4. HMAC-SHA256(..., client_secret) بگیرید و با signature مقایسه کنید.
  5. timestamp را چک کنید (±۵ دقیقه توصیه می‌شود).
  6. مبلغ و reference_id را با سفارش خودتان تطبیق دهید.

۶. Webhook سرور به سرور

Montify بعد از پرداخت موفق یک POST به webhook_url می‌زند.

Headers:

Content-Type: application/json
X-Client-Id: ptr_xxxxxxxx
X-Timestamp: 1720000123
X-Signature: <hex>

Body:

{
  "status": "success",
  "reference_id": "ORDER-1001",
  "session_id": "a1b2c3...",
  "amount": 500000,
  "track_number": "123456789",
  "gateway": "zarinpall",
  "timestamp": "1720000123"
}

تأیید امضای webhook

X-Signature = HMAC-SHA256(stableStringify(body), client_secret)

پاسخ 2xx برگردانید. در غیر این صورت ممکن است ارسال ناموفق ثبت شود.

Webhook فقط وقتی session به paid برسد ارسال می‌شود.


۷. نمونه کد Node.js

const crypto = require('crypto');
const axios = require('axios');

const BASE_URL = 'https://node.example.com';
const CLIENT_ID = process.env.MONTIFY_CLIENT_ID;
const CLIENT_SECRET = process.env.MONTIFY_CLIENT_SECRET;

function stableStringify(obj) {
  if (obj === null || typeof obj !== 'object') return JSON.stringify(obj);
  if (Array.isArray(obj)) return `[${obj.map(stableStringify).join(',')}]`;
  const keys = Object.keys(obj).sort();
  return `{${keys.map((k) => `"${k}":${stableStringify(obj[k])}`).join(',')}}`;
}

function signRequest(method, path, body) {
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const nonce = crypto.randomUUID();
  const bodyString = body ? stableStringify(body) : '';
  const bodyHash = crypto.createHash('sha256').update(bodyString).digest('hex');
  const canonical = [method.toUpperCase(), path, bodyHash, timestamp, nonce].join('\n');
  const signature = crypto.createHmac('sha256', CLIENT_SECRET).update(canonical).digest('hex');
  return {
    'Content-Type': 'application/json',
    'X-Client-Id': CLIENT_ID,
    'X-Timestamp': timestamp,
    'X-Nonce': nonce,
    'X-Signature': signature,
  };
}

async function initiatePayment({ amount, referenceId, gateway, returnUrl, webhookUrl, description }) {
  const path = '/external-payment/initiate';
  const body = {
    amount,
    reference_id: referenceId,
    gateway,
    return_url: returnUrl,
    webhook_url: webhookUrl,
    description,
  };
  const headers = signRequest('POST', path, body);
  const { data } = await axios.post(`${BASE_URL}${path}`, body, { headers });
  return data; // { session_id, payment_url, expires_at, reference_id }
}

function verifyReturnSignature(query) {
  const { signature, ...rest } = query;
  const expected = crypto
    .createHmac('sha256', CLIENT_SECRET)
    .update(stableStringify(rest))
    .digest('hex');
  return crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'));
}

function verifyWebhook(body, signatureHeader) {
  const expected = crypto
    .createHmac('sha256', CLIENT_SECRET)
    .update(stableStringify(body))
    .digest('hex');
  const a = Buffer.from(signatureHeader, 'hex');
  const b = Buffer.from(expected, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// مثال استفاده:
// const result = await initiatePayment({
//   amount: 500000,
//   referenceId: 'ORDER-1001',
//   gateway: 'zarinpall',
//   returnUrl: 'https://yoursite.com/payment/result',
//   webhookUrl: 'https://yoursite.com/api/payment/webhook',
//   description: 'خرید سفارش',
// });
// res.redirect(result.payment_url);

۸. نمونه کد PHP

<?php

function stable_stringify($obj) {
    if (is_null($obj) || !is_array($obj)) {
        return json_encode($obj, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    }
    // تشخیص array لیستی vs associative
    if (array_is_list($obj)) {
        $parts = array_map('stable_stringify', $obj);
        return '[' . implode(',', $parts) . ']';
    }
    ksort($obj);
    $parts = [];
    foreach ($obj as $k => $v) {
        $parts[] = '"' . $k . '":' . stable_stringify($v);
    }
    return '{' . implode(',', $parts) . '}';
}

function sign_request(string $method, string $path, ?array $body, string $clientId, string $secret): array {
    $timestamp = (string) time();
    $nonce = bin2hex(random_bytes(16));
    $bodyString = $body ? stable_stringify($body) : '';
    $bodyHash = hash('sha256', $bodyString);
    $canonical = strtoupper($method) . "\n" . $path . "\n" . $bodyHash . "\n" . $timestamp . "\n" . $nonce;
    $signature = hash_hmac('sha256', $canonical, $secret);

    return [
        'Content-Type: application/json',
        'X-Client-Id: ' . $clientId,
        'X-Timestamp: ' . $timestamp,
        'X-Nonce: ' . $nonce,
        'X-Signature: ' . $signature,
    ];
}

function initiate_payment(array $payload, string $baseUrl, string $clientId, string $secret): array {
    $path = '/external-payment/initiate';
    $headers = sign_request('POST', $path, $payload, $clientId, $secret);
    $ch = curl_init(rtrim($baseUrl, '/') . $path);
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_HTTPHEADER => $headers,
        CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
        CURLOPT_RETURNTRANSFER => true,
    ]);
    $raw = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    $data = json_decode($raw, true);
    if ($code >= 400) {
        throw new RuntimeException($data['message'] ?? 'initiate failed');
    }
    return $data;
}

function verify_return_signature(array $query, string $secret): bool {
    $signature = $query['signature'] ?? '';
    unset($query['signature']);
    $expected = hash_hmac('sha256', stable_stringify($query), $secret);
    return hash_equals($expected, $signature);
}

۹. چک‌لیست پیاده‌سازی سایت شما

  1. client_id / client_secret را فقط در env سرور نگه دارید.
  2. قبل از پرداخت، سفارش خود را با وضعیت «در انتظار پرداخت» ذخیره کنید.
  3. از سرور initiate بزنید و کاربر را به payment_url بفرستید.
  4. صفحه return_url را بسازید و signature را verify کنید.
  5. endpoint webhook بسازید و signature را verify کنید؛ سفارش را آنجا paid کنید.
  6. برای اطمینان، در صورت نیاز GET /status هم بزنید (idempotent).
  7. reference_id را یکتا و قابل ردیابی نگه دارید.
  8. return_url را دقیقاً زیر پیشوند whitelist ثبت‌شده بفرستید.

۱۰. امنیت — خلاصه قوانین

کار درست کار غلط
initiate فقط از سرور ساخت لینک پرداخت در مرورگر کاربر
امضا با client_secret گذاشتن secret در JS فرانت
verify امضای redirect و webhook اعتماد کور به query بدون signature
تطبیق amount با سفارش قبول هر مبلغ برگشتی
whitelist برای return_url قبول هر URL دلخواه

۱۱. پشتیبانی

در صورت خطای مداوم invalid_signature:

  1. ساعت سرور (NTP) را چک کنید.
  2. stableStringify و ترتیب کلیدها را بررسی کنید.
  3. PATH دقیقاً /external-payment/initiate باشد (بدون domain و بدون slash اضافه).
  4. body ارسالی دقیقاً همان چیزی باشد که hash شده (نه serialize متفاوت axios/curl).

برای فعال‌سازی partner جدید یا افزودن دامنه به whitelist با ادمین Montify هماهنگ کنید.