این یک payload وبهوک واقعی است که Stripe ارسال کرده، خلاصه و ویرایششده اما از نظر ساختاری دستنخورده — یک checkout.session.completed این یک payload وبهوک است که Stripe واقعاً ارسال کرده، کوتاه و ویرایششده اما از نظر ساختاری دستنخورده — یک data.object.customer رویداد، همان رویدادی که وقتی کسی پرداخت را در یک صفحهی Checkout به پایان میرساند فعال میشود. بیشتر توسعهدهندهها یک نگاه به این میاندازند، و data.object.amount_totalو
{
"id": "evt_1P8xQ2K7z3n9lWqA00Ff2gLm",
"object": "event",
"api_version": "2024-06-20",
"created": 1725456000,
"type": "checkout.session.completed",
"livemode": true,
"pending_webhooks": 1,
"request": { "id": "req_9F2mA1", "idempotency_key": null },
"data": {
"object": {
"id": "cs_live_a1B2c3D4",
"object": "checkout.session",
"customer": "cus_Q3fZ8xLmN2",
"customer_details": {
"email": "[email protected]",
"tax_ids": []
},
"payment_status": "paid",
"payment_intent": "pi_3P8xQ2K7z3n9lWqA1gH4iJ5k",
"subscription": "sub_1P8xQaK7z3n9lWqA",
"amount_total": 2900,
"currency": "usd",
"metadata": {
"app_user_id": "usr_38821",
"plan": "pro_monthly"
}
}
}
}
id و مسئلهی ایدمپوتنسی که رایگان به ارث میبرید
evt_1P8xQ2K7z3n9lWqA00Ff2gLm را برمیدارند و ادامه میدهند. این معمولاً برای یک دمو مشکلی ندارد. همین دلیلی است که اپلیکیشنها سفارشها را دوبار تحویل میدهند، تمدید اشتراک را از دست میدهند، و اولین ایمیل عصبانی پشتیبانی دربارهی بازپرداختی که «انجام نشد» را دریافت میکنند. بیایید کل ماجرا را تکهتکه بررسی کنیم. دستکم یک باربه نظر میرسد نویز است تا این که متوجه شوید تنها چیزی است که بین شما و یک سفارش تکراری ایستاده. Stripe وبهوکها را idتحویل میدهد، نه دقیقاً یکبار. اگر سرور شما درخواست را میپذیرد اما قبل از ارسال 200 تایماوت میشود، Stripe فرض شکست میکند و همان رویداد را دوباره میفرستد، همان checkout.session.completed ، گاهی چند دقیقه بعد، گاهی روز بعد. اگر handler شما هر بار که idمیبیند بدون بررسی اینکه آیا این processed_webhook_events دقیق را قبلاً پردازش کرده یا نه اشتراک اعطا کند، در نهایت آن را دوبار اعطا خواهید کرد. راهحل یک خط در دیتابیس شماست: یک محدودیت یکتا (unique constraint) روی یک جدول
هدر امضایی که اصلاً داخل بدنه نیست
کلید شده بر اساس این فیلد، که قبل از انجام هر کار دیگری بررسی میشود. این کمجلوهترین خط کد در کل یکپارچهسازی است و همان خطی است که واقعاً اهمیت دارد. Stripe-Signature payload بالا همان چیزی است که Stripe بهعنوان بدنهی درخواست ارسال میکند. چیزی که نشان نمیدهد هدر
من این باگ دقیق را دوبار در محیط عملیاتی دیدهام — هر دو بار راهحل پنج دقیقهای بود، و هر دو بار ماهها روی سرویس فعال بوده تا کسی متوجه حسابهای «pro» رایگان توی دیتابیس بشود.
است که همراهش میآید — یک timestamp بههمراه یک امضای HMAC-SHA256 که با کلید مخفی امضای وبهوک شما محاسبه شده. اگر تأیید آن را نادیده بگیرید، endpoint وبهوک شما به یک مسیر POST عمومی تبدیل میشود که هر کسی روی اینترنت میتواند با یک JSON دستساز «پرداخت موفق» به آن ضربه بزند و پلن پولی شما را رایگان باز کند. این یک حملهی نظری نیست؛ URLهای endpoint در جاوااسکریپت سمت کلاینت، لاگها، پیستهای Slack نشت میکنند، و اسکنرها دقیقاً بهدنبال چنین شکل مسیری هستند.stripe.webhooks.constructEvent(body, sig, secret)تأیید کردن سه خط با SDK شرکت Stripe برای شما هزینه دارد (
data.object.customer در برابر data.object.customer_details
) و باید روی بدنهی خام و تجزیهنشدهی درخواست اجرا شود — اگر middleware مربوط به JSON یک فریمورک قبل از رسیدن به handler شما آن را به یک آبجکت تجزیه کرده باشد، بررسی امضا با یک عدم تطابق بایتبهبایت شکست میخورد که هیچ ربطی به یک حملهی واقعی ندارد. این رایجترین باگ «چرا وبهوک من همیشه 400 برمیگرداند» گزارششده در فورومهای خود Stripe است. customer این دو زائد بهنظر میرسند اما نیستند. customer_details شناسهی مشتری Stripe است — پایدار، قابل استفادهی مجدد، همان چیزی که بهعنوان کلید خارجی ذخیره میکنید. customer یک عکس فوری از چیزی است که خریدار در آن لحظه در فرم checkout تایپ کرده — ایمیل، شناسهی مالیاتی، گاهی یک نام — و میتواند حتی وقتی که customer خالی (null) است هم وجود داشته باشد، که این در جلسات Checkout یکبارهای اتفاق میافتد که شما از Stripe نخواستهاید یک آبجکت Customer بسازد. اگر منطق onboarding شما
metadata: همان دو فیلدی که خودتان واقعاً آنجا قرار دادهاید
app_user_id رویداد، همان رویدادی که وقتی کسی پرداخت را در یک صفحهی Checkout به پایان میرساند فعال میشود. بیشتر توسعهدهندهها یک نگاه به این میاندازند، و plan را بخواند و فرض کند همیشه پر شده، خریدهای مهمان (guest checkout) بیسروصدا آن را میشکنند. metadataفیلدهای Stripe نیستند — هرچیزی هستند که خودتان هنگام ساخت جلسهی Checkout پیوست کردهاید. این مهمترین تصمیم طراحی در کل یکپارچهسازی است و بهراحتی نادیده گرفته میشود چون راهنمای سریع Stripe روی آن مکث نمیکند. بدون شناسهی کاربری خودتان در customer_details.email ، تنها راه اتصال این پرداخت به یک ردیف در دیتابیس شما تطبیق روی ایمیل است، و ایمیلها تغییر میکنند، اشتباه تایپ میشوند، یا متعلق به کسی هستند که بهجای یک همکار پرداخت میکند. هر handler وبهوکی که من بد نوشتهام، با نگاه به گذشته، همانی بود که سعی میکرد هویت را از
payment_status: مقدار paid تنها گزینه ممکن نیست
بازسازی کند، بهجای اینکه به metadataای که خودش سه مرحله قبل تنظیم کرده بود اعتماد کند. payment_status وسوسهانگیز است که صرف وجود این رویداد را دلیل قطعی پرداخت در نظر بگیرید. اما اینطور نیست، همیشه — unpaid همچنین میتواند no_payment_required باشد (یک جلسه کامل شده اما یک روش پرداخت با تأخیر مانند برداشت بانکی هنوز تسویه نشده) یا checkout.session.completed (یک checkout کاملاً تخفیفدار، یک آزمایش رایگان بدون شارژ کارت هنوز). تحویل محصول بر اساس payment_status: "paid" بدون بررسی این فیلد یعنی ارسال محصول قبل از تأیید واقعی پول. برای هر مبلغی بیشتر از چند دلار، منتظر invoice.paid / payment_intent.succeeded بمانید یا بهتر است تحویل را بر اساس
| نوع رویداد | زمان وقوع | کاری که باید انجام دهید |
|---|---|---|
checkout.session.completed | خریدار فرم Checkout را تکمیل میکند | کلید بزنید بهجای رویداد جلسه. payment_status آن را لاگ کنید، اما قبل از تحویل |
payment_intent.succeeded | پول واقعاً تسویه میشود | نقطهی امن برای تحویل یک خرید یکباره |
invoice.paid | فاکتور اشتراک پرداخت میشود (اولیه یا تمدید) | یک صورتحساب اشتراک پرداخت میشود (اولیه یا تمدید) |
customer.subscription.updated | دسترسی را تمدید کنید، شمارندههای مصرف را ریست کنید | امتیازات را همگامسازی کنید، فرض نکنید این یعنی لغو اشتراک |
charge.refunded | تغییر پلن، تغییر تعداد، فعال یا غیرفعال شدن لغو در پایان دوره | شما یا بانک خریدار یک تراکنش را برمیگرداند |
دسترسی را لغو کنید، این موردی است که بیشتر از همه فراموش میشود
فیلدی که در این payload نیست: بعد چه اتفاقی میافتد
مدت زمانی که Stripe قبل از تسلیم شدن، تلاش برای ارسال یک webhook ناموفق را تکرار میکند



