# راهنمای اتصال پرداخت خارجی (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` مثال: ```json {"amount":1000,"gateway":"zarinpall","reference_id":"ORD-1","return_url":"https://yoursite.com/ok"} ``` نه: ```json {"return_url":"...","amount":1000,...} ``` ### ۳.۳. Signature ```text 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 | خیر | داده دلخواه شما | **نمونه درخواست:** ```http 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: { "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`:** ```json { "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=` **پاسخ نمونه:** ```json { "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 ×tamp=1720000123 &signature= ``` | پارامتر | توضیح | |---------|--------| | `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: ``` **Body:** ```json { "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 ```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 $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 هماهنگ کنید.