اتصال درگاه پرداخت به اپلیکیشن؛ زرینپال بدون نوشتن بکاند
درگاه پرداخت را نمیشود مستقیم از اپ صدا زد. چراییاش، قانون تطابق دامنهی شاپرک، و ساخت کل جریان زرینپال با دو اندپوینت — با کد واقعی و جدول خطاها.
در این مطلب
اتصال درگاه پرداخت به اپلیکیشن یک قدم دارد که هیچ راهی برای دور زدنش نیست: مبلغ باید جایی بیرون از اپ و مرورگر تعیین شود و پرداخت هم همانجا تأیید شود. یعنی حتی برای یک فروشگاه کوچک هم به کدی نیاز دارید که روی سرور اجرا شود — ولی آن کد لازم نیست یک بکاند کامل باشد. در این راهنما اول میگوییم چرا صدا زدن مستقیم درگاه از داخل اپ خطرناک است، بعد قانونی را توضیح میدهیم که شاپرک الزام کرده و بیشتر آموزشها جا میاندازند، و در آخر کل جریان زرینپال را با دو اندپوینت کوتاه روی بکاند آماده میسازیم؛ بدون سرور، بدون دیپلوی.
چرا نمیشود درگاه پرداخت را مستقیم از اپلیکیشن صدا زد؟
سؤالی که در انجمنهای فارسی مدام تکرار میشود همین است: «مرچنتکد را داخل اپ اندروید بگذارم و مستقیم به زرینپال درخواست بدهم، مشکلی دارد؟» سه مشکل دارد و هر سهشان جدیاند.
مرچنتکد لو میرود. هر رشتهای که داخل اپ اندروید یا جاوااسکریپت سایت باشد قابل استخراج است؛ دیکامپایل اپ یا باز کردن تب Network مرورگر کافی است. مرچنتکد شناسهی پذیرندگی شماست و کسی که آن را داشته باشد میتواند از طرف شما درخواست پرداخت بسازد.
مبلغ دستکاری میشود. اگر مبلغ را کلاینت تعیین کند، کلاینت میتواند عوضش کند. سفارش دو میلیون تومانی با درخواست هزار تومانی به درگاه میرود و بانک هم همان هزار تومان را میگیرد — چون از نظر بانک، مبلغی که پذیرنده اعلام کرده همین بوده.
تأیید پرداخت بیمعنا میشود. پرداخت موفق با برگشتن کاربر به اپ تمام نمیشود؛ تا وقتی درخواست وریفای از سمت شما فرستاده نشده، پول نزد بانک بلوکه است و بعد از مدتی برمیگردد. اگر این وریفای را کلاینت بفرستد، هر کسی میتواند نتیجهاش را جعل کند و بگوید «پرداخت شد».
زرینپال خودش یک محافظ برای دستکاری مبلغ دارد: اگر مبلغی که در وریفای
میفرستید با مبلغ واقعاً پرداختشده فرق کند، خطای -50 میگیرید و تراکنش
تأیید نمیشود. این محافظ فقط وقتی کار میکند که مبلغ وریفای را سرور از روی
سفارش بخواند، نه از بدنهی درخواست کلاینت.
پس نقش آن لایهی سمت سرور دقیقاً سه چیز است: نگهداشتن مرچنتکد، تعیین مبلغ از روی دادهی خودتان، و فرستادن وریفای. باقی کارها — باز کردن صفحهی پرداخت و برگرداندن کاربر — کار مرورگر است.
قانون تطابق دامنه؛ چیزی که آموزشها جا میاندازند
این بند را جدی بگیرید، چون معماریتان را تعیین میکند. زرینپال از مرداد ۱۴۰۴ الزامی را اعلام کرده که ریشهاش در قواعد امنیتی شاپرک است:
دامنه نشانی صفحه آغازگر پرداخت با نشانی نتیجه پرداخت و هر دوی آنها با دامنه رسمی درگاه (ثبت شده در زرینپال و شاپرک) تطابق داشته باشد.
یعنی سه دامنه باید یکی باشند: صفحهای که کاربر از آن به درگاه میرود، آدرس بازگشت (callback_url)، و دامنهای که موقع گرفتن درگاه ثبت کردهاید. عدم تطابق فقط یک خطای فنی نیست؛ در متن رسمی زرینپال تخلف حساب میشود و میتواند به تعلیق درگاه برسد.
نتیجهی عملیاش برای هر کسی که بکاندش روی سرویس دیگری اجرا میشود همین یک جمله است: آدرس بازگشت را روی دامنهی خودتان بگذارید، نه روی دامنهی سرویس بکاند. درخواستهایی که کد شما به API میزند از این قاعده مستثنا هستند — آنها فراخوانی پسزمینهاند، نه صفحهای که کاربر در آن جابهجا میشود. چیزی که باید روی دامنهی ثبتشده بنشیند، صفحهی «شروع پرداخت» و صفحهی «نتیجهی پرداخت» است.
کوچکترین بکاندی که برای پرداخت لازم دارید
با این دو محدودیت، سه راه واقعی باقی میماند:
| راه | کدی که مینویسید | چیزی که نگه میدارید | کِی انتخاب درستی است |
|---|---|---|---|
| سرور اختصاصی (لاراول، Node، جنگو) | کل بکاند | سرور، دیپلوی، SSL، بکاپ | وقتی از قبل بکاند و تیمش را دارید |
| افزونهی فروشگاهساز | هیچ | هیچ | وقتی فروشگاهتان همان CMS است و اپ اختصاصی ندارید |
| اندپوینت روی بکاند آماده | دو تابع کوتاه | هیچ | وقتی اپ یا سایت اختصاصی دارید ولی بکاند نه |
راه سوم همان چیزی است که در فیکارو «اندپوینت HTTP» نام دارد: یک آدرس اختصاصی که کدش را خودتان به جاوااسکریپت مینویسید و از اپ صدایش میزنید. کد روی زیرساخت ما اجرا میشود، به جدولهای همان پروژه دسترسی دارد و میتواند سرویس بیرونی صدا بزند. برای پرداخت دقیقاً دو تا لازم داریم: یکی برای شروع، یکی برای تأیید.
جریان کامل شش قدم است: اپ شما pay-start را صدا میزند ← اندپوینت مبلغ را از سفارش میخواند و از زرینپال آدرس پرداخت میگیرد ← کاربر به صفحهی درگاه میرود ← بعد از پرداخت به آدرس بازگشتِ روی دامنهی خودتان برمیگردد ← آن صفحه pay-verify را صدا میزند ← اندپوینت وریفای میکند و سفارش را «پرداختشده» میکند.
قدم ۱: اندپوینت شروع پرداخت
در صفحهی «فانکشنها»ی پروژه یک اندپوینت HTTP به نام pay-start بسازید و دسترسیاش را روی «کاربران واردشده» بگذارید، چون کسی که پرداخت را شروع میکند خریدارِ واردشده به اپ شماست. مرچنتکد را هاردکد نکنید؛ از دکمهی کلید در نوار فانکشنها بهعنوان secret ذخیرهاش کنید تا با ctx.secrets خوانده شود و در تاریخچهی اجرا هم ظاهر نشود.
نکتهی اصلی این کد یک خط است: مبلغ از دیتابیس خوانده میشود، نه از بدنهی درخواست. ctx.db هم با دسترسی خودِ کاربر کار میکند، پس سفارش کاربر دیگری اصلاً خوانده نمیشود.
const orderId = ctx.request.body?.orderId;
if (!orderId) ctx.reject("orderId لازم است");
const order = ctx.db.get("order", orderId);
if (order.status !== "pending") ctx.reject("این سفارش قابل پرداخت نیست");
const res = ctx.http.post(
"https://payment.zarinpal.com/pg/v4/payment/request.json",
{
merchant_id: ctx.secrets.ZARINPAL_MERCHANT_ID,
currency: "IRR",
amount: order.totalToman * 10, // تومان × ۱۰ = ریال
description: "سفارش " + order.code,
callback_url: "https://shop.example.ir/pay/callback",
metadata: { auto_verify: true },
},
);
const data = res.json?.data;
if (!res.ok || data?.code !== 100) {
ctx.log("zarinpal request failed:", res.text);
ctx.respond(502, { error: "درگاه پرداخت پاسخ نداد" });
}
ctx.db.admin.update("order", orderId, { authority: data.authority });
ctx.respond({
payUrl: "https://payment.zarinpal.com/pg/StartPay/" + data.authority,
});
سه نکته که هرکدام یک ساعت اشکالزدایی صرفهجویی میکنند. اول، واحد پول: قیمتها را معمولاً به تومان نگه میدارید ولی API نسخهی ۴ زرینپال ریال میگیرد؛ ضرب در ۱۰ را فراموش کنید، مبلغ یکدهم میشود. دوم، auto_verify: با این متادیتا، اگر آدرس بازگشت به هر دلیلی به شما نرسید، زرینپال خودش پرداخت را نزد بانک قطعی میکند و پول مشتری بیدلیل برنمیگردد. سوم، authority: آن را همانجا روی سفارش ذخیره کنید — کلید تشخیص تراکنش در مرحلهی بعد همین است.
قدم ۲: صفحهی بازگشت روی دامنهی خودتان
این تنها قطعهای است که باید روی دامنهی ثبتشدهی خودتان بنشیند، و میتواند یک صفحهی ساده باشد. زرینپال کاربر را با دو پارامتر کوئری برمیگرداند: Authority و Status.
// https://shop.example.ir/pay/callback
const q = new URLSearchParams(location.search);
const authority = q.get("Authority");
if (q.get("Status") !== "OK") {
show("پرداخت لغو شد");
} else {
const url = "https://api.fikaro.ir/my-shop/v1/fn/pay-verify";
const r = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + userToken,
},
body: JSON.stringify({ authority }),
});
const out = await r.json();
show(out.status === "paid" ? "پرداخت موفق" : "پرداخت ناموفق");
}
Status: OK یعنی کاربر مرحلهی بانک را رد کرده، نه اینکه پول به حساب شما نشسته. تا وریفای انجام نشود، تراکنش قطعی نیست — بنابراین این صفحه فقط پیام موقت نشان میدهد و تصمیم نهایی را از جواب اندپوینت بعدی میگیرد. توکن کاربر همان JWTای است که موقع ورود گرفتهاید؛ جزئیاتش در کلیدها و احراز هویت آمده است.
قدم ۳: اندپوینت تأیید و ثبت پرداخت
اندپوینت دوم به نام pay-verify تنها جایی است که اجازه دارد سفارش را «پرداختشده» کند.
const authority = ctx.request.body?.authority;
if (!authority) ctx.reject("authority لازم است");
const found = ctx.db.admin.list("order", {
filter: { authority },
limit: 1,
});
const order = found[0];
if (!order) ctx.reject("سفارشی با این تراکنش پیدا نشد");
if (order.status === "paid") {
ctx.respond({ status: "paid", refId: order.refId });
}
const res = ctx.http.post(
"https://payment.zarinpal.com/pg/v4/payment/verify.json",
{
merchant_id: ctx.secrets.ZARINPAL_MERCHANT_ID,
amount: order.totalToman * 10, // باز هم از سفارش، نه از کلاینت
authority,
},
);
const data = res.json?.data;
if (data?.code !== 100 && data?.code !== 101) {
ctx.db.admin.update("order", order.id, { status: "failed" });
ctx.respond(402, { status: "failed", code: data?.code });
}
const refId = String(data.ref_id);
ctx.db.admin.update("order", order.id, { status: "paid", refId });
ctx.respond({ status: "paid", refId });
اینجا ctx.db.admin استفاده شده نه ctx.db، چون تغییر وضعیت سفارش تصمیم سیستم است نه کاربر — کاربر نباید از راه API معمولی بتواند status را روی paid بگذارد. نوشتن با هر دو دسته همچنان از اعتبارسنجی و قوانین همان پروژه رد میشود.
آن شرط کوتاهِ وسط کد هم تزئینی نیست: اگر سفارش از قبل پرداختشده باشد، بدون فرستادن وریفای دوم همان نتیجه برگردانده میشود. کاربری که صفحهی نتیجه را رفرش میکند یا لینک را دوباره باز میکند، سناریوی هر روز است.
کد ۱۰۱ را حتماً مثل موفقیت رفتار کنید. زرینپال برای تراکنشی که قبلاً وریفای شده همین را برمیگرداند و اگر آن را خطا حساب کنید، سفارشِ واقعاً پرداختشده را «ناموفق» علامت میزنید — همراه با auto_verify قدم اول، این حالت نادر نیست.
قبل از فعال کردن اندپوینت، از «اجرای آزمایشی» استفاده کنید: درخواست شبیهسازیشده میفرستد، خواندنها واقعیاند و نوشتنها رد میشوند. برای آزمایش کل جریان هم زرینپال محیط سندباکس دارد؛ کافی است آدرسها را به sandbox.zarinpal.com تغییر دهید.
خطاهای زرینپال که سر راهاندازی میگیرید
هر پیادهسازی درگاه از چند تا از اینها رد میشود. متنها از فهرست رسمی خطاهای زرینپال است:
| کد | متن رسمی زرینپال | در عمل یعنی |
|---|---|---|
| -9 | خطای اعتبار سنجی | یکی از فیلدهای درخواست اشتباه است — معمولاً مبلغ یا آدرس بازگشت |
| -10 | ای پی یا مرچنت کد پذیرنده صحیح نیست | مرچنتکد غلط است یا درخواست از سرور غیرمجاز رفته |
| -11 | مرچنت کد فعال نیست | درگاه هنوز تأیید نشده؛ با پشتیبانی زرینپال حل میشود |
| -15 | درگاه پرداخت به حالت تعلیق در آمده است | همان چیزی که تخلف تطابق دامنه میتواند به آن برسد |
| -50 | مبلغ پرداخت شده با مقدار مبلغ ارسالی در متد وریفای متفاوت است | مبلغ وریفای با مبلغ درخواست نمیخواند |
| -53 | پرداخت متعلق به این مرچنت کد نیست | تراکنش سندباکس را با مرچنتکد پروداکشن وریفای کردهاید، یا برعکس |
| -54 | اتوریتی نامعتبر است | مقدار authority اشتباه یا منقضی است |
| 100 / 101 | عملیات موفق / تراکنش وریفای شده است | هر دو یعنی موفق — ۱۰۱ را خطا حساب نکنید |
اگر خطا در همان request.json رخ بدهد، معمولاً مشکل تنظیمات درگاه است نه کد شما؛ اگر در verify.json رخ بدهد، تقریباً همیشه مبلغ یا محیط (سندباکس در برابر پروداکشن) است.
چه چیزی همچنان کار شماست
سه مرز را از اول بدانید. رابط کاربری پرداخت — صفحهی سبد خرید، دکمه، و صفحهی نتیجه — کار خودتان است؛ فیکارو بکاند میدهد، فرانتاند نه. آشتیدادن تراکنشهای معلق هم امروز دستی است: اگر کاربر بعد از پرداخت مرورگر را ببندد و آدرس بازگشت هرگز صدا زده نشود، auto_verify پول را نزد بانک قطعی میکند ولی سفارش شما در وضعیت pending میماند تا کسی وریفای را بفرستد. تا وقتی اجرای زمانبندیشده اضافه نشده، سادهترین راه یک اندپوینت سوم است که سفارشهای معلق را میخواند و برایشان وریفای میفرستد و شما هر روز یک بار صدایش میزنید.
و آخرین مرز، خودِ درگاه: مرچنتکد را باید از زرینپال (یا هر PSP دیگری) با مدارک کسبوکارتان بگیرید. هیچ سرویسی این را جای شما انجام نمیدهد.
اگر بکاندی که این اندپوینتها رویش مینشینند را هنوز نساختهاید، ساخت API بدون کدنویسی از صفر تا اولین درخواست جلو میرود، و کد اتصال از اپ موبایل در بکاند فلاتر و ریاکتنیتیو آمده است. هزینهی اجرای پروژه هم ساعتی و از کیفپول است — صفحهی تعرفهها نشان میدهد هر مبلغ چند روز پروژه را روشن نگه میدارد.
سوالات متداول
- میشود درگاه پرداخت را مستقیم از اپلیکیشن اندروید وصل کرد؟
- نه. مرچنتکدی که داخل اپ باشد قابل استخراج است، مبلغی که کلاینت تعیین کند قابل دستکاری است، و وریفایی که کلاینت بفرستد قابل جعل است. حداقل چیزی که لازم دارید کدی است که روی سرور اجرا شود و مرچنتکد و مبلغ و وریفای را نگه دارد — این میتواند دو تابع کوتاه روی یک بکاند آماده باشد، نه یک سرور کامل.
- آدرس بازگشت درگاه پرداخت را روی چه دامنهای بگذارم؟
- روی همان دامنهای که درگاه با آن ثبت شده و کاربر پرداخت را از آن شروع کرده. زرینپال بهپیروی از قواعد شاپرک تطابق این سه دامنه را الزامی کرده و عدم تطابق میتواند به تعلیق درگاه برسد. پس صفحهی نتیجهی پرداخت روی سایت خودتان میماند و آن صفحه اندپوینت تأیید را صدا میزند.
- اگر کاربر بعد از پرداخت صفحه را ببندد، پولش چه میشود؟
- اگر در درخواست پرداخت metadata.auto_verify را فرستاده باشید، زرینپال تراکنش را نزد بانک قطعی میکند و پول برنمیگردد؛ ولی سفارش در سیستم شما تا وقتی وریفای نرود معلق میماند. راهحل عملی یک اندپوینت است که سفارشهای معلق را پیدا کند و برایشان وریفای بفرستد.
- زرینپال کد ۱۰۱ برمیگرداند؛ یعنی خطا؟
- نه، یعنی این تراکنش قبلاً وریفای شده بود. باید دقیقاً مثل کد ۱۰۰ با آن رفتار کنید و سفارش را پرداختشده ثبت کنید. خطا حساب کردن ۱۰۱ یکی از رایجترین باگهای پیادهسازی درگاه است و نتیجهاش سفارش پرداختشدهای است که «ناموفق» علامت خورده.
مطالب مرتبط
- ۷ دقیقه مطالعه
آپلود عکس و فایل در اپلیکیشن؛ بدون سرور، با یک درخواست
آپلود فایل از اپ به بکاند با یک درخواست multipart: آدرس عمومی برای تگ img، فایل خصوصی پشت توکن، تصویر کوچک با ?w=، سقف حجم هر پلن و سه خطایی که وقت میگیرد.
- ۳ دقیقه مطالعه
بکاند فروشگاه اینترنتی؛ کاتالوگ، سبد، سفارش و پرداخت
مدل دادهی کامل یک فروشگاه با سطح دسترسی هر جدول، و دو چیزی که همیشه اشتباه پیاده میشوند: قیمت لحظهی خرید، و اینکه موجودی را کِی باید کم کرد.