[{"data":1,"prerenderedAt":2734},["ShallowReactive",2],{"header-latest":3,"post-\u002Fru\u002Fblog\u002Fsaas-stripe-billing-integration":1368},[4],{"id":5,"title":6,"body":7,"date":1342,"description":1343,"extension":1344,"faq":1345,"image":1355,"lang":1356,"meta":1357,"navigation":106,"path":1358,"published":106,"readTime":213,"seo":1359,"stem":1360,"tags":1361,"updated":1342,"__hash__":1367},"blog\u002Fblog\u002Fru\u002Ftelegram-web-apps-sells.md","Telegram Web Apps (TWA): Как запустить полноценный SaaS внутри Telegram",{"type":8,"value":9,"toc":1331},"minimark",[10,19,22,25,30,33,44,51,61,68,71,854,858,861,866,872,920,924,927,1212,1216,1225,1228,1266,1279,1287,1291,1311,1314,1327],[11,12,13,14,18],"p",{},"Telegram давно перешагнул рамки классического мессенджера для обмена сообщениями, превратившись в полноценную кроссплатформенную среду выполнения приложений. С появлением технологии ",[15,16,17],"strong",{},"Telegram Web Apps (TWA)",", также известной как Telegram Mini Apps, разработчики могут запускать интерактивные SPA-приложения прямо внутри интерфейса мессенджера.",[11,20,21],{},"Это дает стартапам и бизнесу мгновенный бесшовный доступ к аудитории более 900 миллионов активных пользователей. Размещая свой B2B SaaS, личный кабинет CRM или бизнес-инструмент в боте Telegram, вы устраняете классические барьеры воронки: необходимость скачивать приложения из сторов, регистрироваться и авторизовываться на сторонних сайтах.",[11,23,24],{},"В этом техническом руководстве мы разберем архитектуру TWA, напишем безопасный бэкенд-валидатор на Node.js\u002FTypeScript и настроим синхронизацию стилей на фронтенде.",[26,27,29],"h2",{"id":28},"архитектура-экосистемы-telegram-mini-apps","Архитектура экосистемы Telegram Mini Apps",[11,31,32],{},"В отличие от стандартных веб-приложений, TWA имеет двухстороннюю связь с клиентом. Фронтенд работает в изолированном компоненте WebView, контролируемом клиентом Telegram, в то время как бэкенд взаимодействует как с API Telegram Bot, так и с базой данных приложения.",[34,35,41],"pre",{"className":36,"code":38,"language":39,"meta":40},[37],"language-text","+-----------------------------------------------------------+\n|                     Клиент Telegram                       |\n|  +-----------------------------------------------------+  |\n|  |                 Mini App (WebView UI)               |  |\n|  |     (Использует window.Telegram.WebApp SDK)         |  |\n|  +--------------------------+--------------------------+  |\n+-----------------------------|-----------------------------+\n       | (initData параметры) | (Защищенный HTTPS-запрос)\n       v                      v\n+--------------+       +------------------------------------+\n| Telegram API |       |            Ваш Бэкенд              |\n|  (Вебхуки)   |       | (Проверяет подпись через HMAC-256) |\n+------+-------+       +-----------------+------------------+\n       |                                 |\n       +----------------> [Синхр. БД] \u003C---+\n","text","",[42,43,38],"code",{"__ignoreMap":40},[26,45,47,48],{"id":46},"_1-безопасность-валидация-строки-initdata","1. Безопасность: Валидация строки ",[42,49,50],{},"initData",[11,52,53,54,56,57,60],{},"Когда пользователь открывает Mini App, Telegram передает на фронтенд строку параметров ",[42,55,50],{}," (содержащую данные профиля пользователя, тему оформления, контекст запуска) и защитную подпись ",[42,58,59],{},"hash",".",[11,62,63,64,67],{},"Чтобы злоумышленники не могли подделать идентификатор пользователя (User ID) или эмулировать оплату тарифа, вы ",[15,65,66],{},"обязаны валидировать этот хэш на вашем бэкенде"," с помощью токена вашего Telegram-бота.",[11,69,70],{},"Вот реализация валидации на TypeScript\u002FNode.js:",[34,72,76],{"className":73,"code":74,"language":75,"meta":40,"style":40},"language-typescript shiki shiki-themes github-dark","import crypto from 'crypto';\n\ninterface TelegramUserData {\n  id: number;\n  first_name: string;\n  last_name?: string;\n  username?: string;\n  language_code?: string;\n  is_premium?: boolean;\n}\n\ninterface ValidationResult {\n  isValid: boolean;\n  user?: TelegramUserData;\n}\n\nexport function verifyTelegramInitData(rawQueryString: string, botToken: string): ValidationResult {\n  const urlParams = new URLSearchParams(rawQueryString);\n  const hash = urlParams.get('hash');\n  \n  if (!hash) {\n    return { isValid: false };\n  }\n\n  \u002F\u002F 1. Сортируем все переданные ключи в алфавитном порядке, кроме самого hash\n  const keys = Array.from(urlParams.keys()).filter(key => key !== 'hash').sort();\n  \n  \u002F\u002F 2. Восстанавливаем проверочную строку данных\n  const dataCheckString = keys\n    .map(key => `${key}=${urlParams.get(key)}`)\n    .join('\\n');\n\n  \u002F\u002F 3. Создаем секретный ключ\n  \u002F\u002F Мы используем константную строку \"WebAppData\" для шифрования токена бота\n  const secretKey = crypto\n    .createHmac('sha256', 'WebAppData')\n    .update(botToken)\n    .digest();\n\n  \u002F\u002F 4. Вычисляем хэш-сигнатуру от проверочной строки\n  const computedHash = crypto\n    .createHmac('sha256', secretKey)\n    .update(dataCheckString)\n    .digest('hex');\n\n  \u002F\u002F 5. Безопасно сравниваем вычисленный хэш с тем, который прислал клиент\n  const isValid = computedHash === hash;\n\n  if (!isValid) {\n    return { isValid: false };\n  }\n\n  \u002F\u002F Десериализуем объект пользователя, если валидация прошла успешно\n  try {\n    const userRaw = urlParams.get('user');\n    const user: TelegramUserData = userRaw ? JSON.parse(userRaw) : undefined;\n    return { isValid: true, user };\n  } catch (error) {\n    return { isValid: true };\n  }\n}\n","typescript",[42,77,78,101,108,121,137,150,163,175,187,200,206,211,221,233,245,250,255,296,317,341,347,362,377,383,388,395,448,453,459,472,514,534,539,545,551,564,584,595,605,610,616,628,642,652,666,671,677,696,701,713,724,729,734,740,748,770,808,821,833,844,849],{"__ignoreMap":40},[79,80,83,87,91,94,98],"span",{"class":81,"line":82},"line",1,[79,84,86],{"class":85},"snl16","import",[79,88,90],{"class":89},"s95oV"," crypto ",[79,92,93],{"class":85},"from",[79,95,97],{"class":96},"sU2Wk"," 'crypto'",[79,99,100],{"class":89},";\n",[79,102,104],{"class":81,"line":103},2,[79,105,107],{"emptyLinePlaceholder":106},true,"\n",[79,109,111,114,118],{"class":81,"line":110},3,[79,112,113],{"class":85},"interface",[79,115,117],{"class":116},"svObZ"," TelegramUserData",[79,119,120],{"class":89}," {\n",[79,122,124,128,131,135],{"class":81,"line":123},4,[79,125,127],{"class":126},"s9osk","  id",[79,129,130],{"class":85},":",[79,132,134],{"class":133},"sDLfK"," number",[79,136,100],{"class":89},[79,138,140,143,145,148],{"class":81,"line":139},5,[79,141,142],{"class":126},"  first_name",[79,144,130],{"class":85},[79,146,147],{"class":133}," string",[79,149,100],{"class":89},[79,151,153,156,159,161],{"class":81,"line":152},6,[79,154,155],{"class":126},"  last_name",[79,157,158],{"class":85},"?:",[79,160,147],{"class":133},[79,162,100],{"class":89},[79,164,166,169,171,173],{"class":81,"line":165},7,[79,167,168],{"class":126},"  username",[79,170,158],{"class":85},[79,172,147],{"class":133},[79,174,100],{"class":89},[79,176,178,181,183,185],{"class":81,"line":177},8,[79,179,180],{"class":126},"  language_code",[79,182,158],{"class":85},[79,184,147],{"class":133},[79,186,100],{"class":89},[79,188,190,193,195,198],{"class":81,"line":189},9,[79,191,192],{"class":126},"  is_premium",[79,194,158],{"class":85},[79,196,197],{"class":133}," boolean",[79,199,100],{"class":89},[79,201,203],{"class":81,"line":202},10,[79,204,205],{"class":89},"}\n",[79,207,209],{"class":81,"line":208},11,[79,210,107],{"emptyLinePlaceholder":106},[79,212,214,216,219],{"class":81,"line":213},12,[79,215,113],{"class":85},[79,217,218],{"class":116}," ValidationResult",[79,220,120],{"class":89},[79,222,224,227,229,231],{"class":81,"line":223},13,[79,225,226],{"class":126},"  isValid",[79,228,130],{"class":85},[79,230,197],{"class":133},[79,232,100],{"class":89},[79,234,236,239,241,243],{"class":81,"line":235},14,[79,237,238],{"class":126},"  user",[79,240,158],{"class":85},[79,242,117],{"class":116},[79,244,100],{"class":89},[79,246,248],{"class":81,"line":247},15,[79,249,205],{"class":89},[79,251,253],{"class":81,"line":252},16,[79,254,107],{"emptyLinePlaceholder":106},[79,256,258,261,264,267,270,273,275,277,280,283,285,287,290,292,294],{"class":81,"line":257},17,[79,259,260],{"class":85},"export",[79,262,263],{"class":85}," function",[79,265,266],{"class":116}," verifyTelegramInitData",[79,268,269],{"class":89},"(",[79,271,272],{"class":126},"rawQueryString",[79,274,130],{"class":85},[79,276,147],{"class":133},[79,278,279],{"class":89},", ",[79,281,282],{"class":126},"botToken",[79,284,130],{"class":85},[79,286,147],{"class":133},[79,288,289],{"class":89},")",[79,291,130],{"class":85},[79,293,218],{"class":116},[79,295,120],{"class":89},[79,297,299,302,305,308,311,314],{"class":81,"line":298},18,[79,300,301],{"class":85},"  const",[79,303,304],{"class":133}," urlParams",[79,306,307],{"class":85}," =",[79,309,310],{"class":85}," new",[79,312,313],{"class":116}," URLSearchParams",[79,315,316],{"class":89},"(rawQueryString);\n",[79,318,320,322,325,327,330,333,335,338],{"class":81,"line":319},19,[79,321,301],{"class":85},[79,323,324],{"class":133}," hash",[79,326,307],{"class":85},[79,328,329],{"class":89}," urlParams.",[79,331,332],{"class":116},"get",[79,334,269],{"class":89},[79,336,337],{"class":96},"'hash'",[79,339,340],{"class":89},");\n",[79,342,344],{"class":81,"line":343},20,[79,345,346],{"class":89},"  \n",[79,348,350,353,356,359],{"class":81,"line":349},21,[79,351,352],{"class":85},"  if",[79,354,355],{"class":89}," (",[79,357,358],{"class":85},"!",[79,360,361],{"class":89},"hash) {\n",[79,363,365,368,371,374],{"class":81,"line":364},22,[79,366,367],{"class":85},"    return",[79,369,370],{"class":89}," { isValid: ",[79,372,373],{"class":133},"false",[79,375,376],{"class":89}," };\n",[79,378,380],{"class":81,"line":379},23,[79,381,382],{"class":89},"  }\n",[79,384,386],{"class":81,"line":385},24,[79,387,107],{"emptyLinePlaceholder":106},[79,389,391],{"class":81,"line":390},25,[79,392,394],{"class":393},"sAwPA","  \u002F\u002F 1. Сортируем все переданные ключи в алфавитном порядке, кроме самого hash\n",[79,396,398,400,403,405,408,410,413,416,419,422,424,427,430,433,436,439,442,445],{"class":81,"line":397},26,[79,399,301],{"class":85},[79,401,402],{"class":133}," keys",[79,404,307],{"class":85},[79,406,407],{"class":89}," Array.",[79,409,93],{"class":116},[79,411,412],{"class":89},"(urlParams.",[79,414,415],{"class":116},"keys",[79,417,418],{"class":89},"()).",[79,420,421],{"class":116},"filter",[79,423,269],{"class":89},[79,425,426],{"class":126},"key",[79,428,429],{"class":85}," =>",[79,431,432],{"class":89}," key ",[79,434,435],{"class":85},"!==",[79,437,438],{"class":96}," 'hash'",[79,440,441],{"class":89},").",[79,443,444],{"class":116},"sort",[79,446,447],{"class":89},"();\n",[79,449,451],{"class":81,"line":450},27,[79,452,346],{"class":89},[79,454,456],{"class":81,"line":455},28,[79,457,458],{"class":393},"  \u002F\u002F 2. Восстанавливаем проверочную строку данных\n",[79,460,462,464,467,469],{"class":81,"line":461},29,[79,463,301],{"class":85},[79,465,466],{"class":133}," dataCheckString",[79,468,307],{"class":85},[79,470,471],{"class":89}," keys\n",[79,473,475,478,481,483,485,487,490,492,495,498,500,502,504,506,508,511],{"class":81,"line":474},30,[79,476,477],{"class":89},"    .",[79,479,480],{"class":116},"map",[79,482,269],{"class":89},[79,484,426],{"class":126},[79,486,429],{"class":85},[79,488,489],{"class":96}," `${",[79,491,426],{"class":89},[79,493,494],{"class":96},"}=${",[79,496,497],{"class":89},"urlParams",[79,499,60],{"class":96},[79,501,332],{"class":116},[79,503,269],{"class":96},[79,505,426],{"class":89},[79,507,289],{"class":96},[79,509,510],{"class":96},"}`",[79,512,513],{"class":89},")\n",[79,515,517,519,522,524,527,530,532],{"class":81,"line":516},31,[79,518,477],{"class":89},[79,520,521],{"class":116},"join",[79,523,269],{"class":89},[79,525,526],{"class":96},"'",[79,528,529],{"class":133},"\\n",[79,531,526],{"class":96},[79,533,340],{"class":89},[79,535,537],{"class":81,"line":536},32,[79,538,107],{"emptyLinePlaceholder":106},[79,540,542],{"class":81,"line":541},33,[79,543,544],{"class":393},"  \u002F\u002F 3. Создаем секретный ключ\n",[79,546,548],{"class":81,"line":547},34,[79,549,550],{"class":393},"  \u002F\u002F Мы используем константную строку \"WebAppData\" для шифрования токена бота\n",[79,552,554,556,559,561],{"class":81,"line":553},35,[79,555,301],{"class":85},[79,557,558],{"class":133}," secretKey",[79,560,307],{"class":85},[79,562,563],{"class":89}," crypto\n",[79,565,567,569,572,574,577,579,582],{"class":81,"line":566},36,[79,568,477],{"class":89},[79,570,571],{"class":116},"createHmac",[79,573,269],{"class":89},[79,575,576],{"class":96},"'sha256'",[79,578,279],{"class":89},[79,580,581],{"class":96},"'WebAppData'",[79,583,513],{"class":89},[79,585,587,589,592],{"class":81,"line":586},37,[79,588,477],{"class":89},[79,590,591],{"class":116},"update",[79,593,594],{"class":89},"(botToken)\n",[79,596,598,600,603],{"class":81,"line":597},38,[79,599,477],{"class":89},[79,601,602],{"class":116},"digest",[79,604,447],{"class":89},[79,606,608],{"class":81,"line":607},39,[79,609,107],{"emptyLinePlaceholder":106},[79,611,613],{"class":81,"line":612},40,[79,614,615],{"class":393},"  \u002F\u002F 4. Вычисляем хэш-сигнатуру от проверочной строки\n",[79,617,619,621,624,626],{"class":81,"line":618},41,[79,620,301],{"class":85},[79,622,623],{"class":133}," computedHash",[79,625,307],{"class":85},[79,627,563],{"class":89},[79,629,631,633,635,637,639],{"class":81,"line":630},42,[79,632,477],{"class":89},[79,634,571],{"class":116},[79,636,269],{"class":89},[79,638,576],{"class":96},[79,640,641],{"class":89},", secretKey)\n",[79,643,645,647,649],{"class":81,"line":644},43,[79,646,477],{"class":89},[79,648,591],{"class":116},[79,650,651],{"class":89},"(dataCheckString)\n",[79,653,655,657,659,661,664],{"class":81,"line":654},44,[79,656,477],{"class":89},[79,658,602],{"class":116},[79,660,269],{"class":89},[79,662,663],{"class":96},"'hex'",[79,665,340],{"class":89},[79,667,669],{"class":81,"line":668},45,[79,670,107],{"emptyLinePlaceholder":106},[79,672,674],{"class":81,"line":673},46,[79,675,676],{"class":393},"  \u002F\u002F 5. Безопасно сравниваем вычисленный хэш с тем, который прислал клиент\n",[79,678,680,682,685,687,690,693],{"class":81,"line":679},47,[79,681,301],{"class":85},[79,683,684],{"class":133}," isValid",[79,686,307],{"class":85},[79,688,689],{"class":89}," computedHash ",[79,691,692],{"class":85},"===",[79,694,695],{"class":89}," hash;\n",[79,697,699],{"class":81,"line":698},48,[79,700,107],{"emptyLinePlaceholder":106},[79,702,704,706,708,710],{"class":81,"line":703},49,[79,705,352],{"class":85},[79,707,355],{"class":89},[79,709,358],{"class":85},[79,711,712],{"class":89},"isValid) {\n",[79,714,716,718,720,722],{"class":81,"line":715},50,[79,717,367],{"class":85},[79,719,370],{"class":89},[79,721,373],{"class":133},[79,723,376],{"class":89},[79,725,727],{"class":81,"line":726},51,[79,728,382],{"class":89},[79,730,732],{"class":81,"line":731},52,[79,733,107],{"emptyLinePlaceholder":106},[79,735,737],{"class":81,"line":736},53,[79,738,739],{"class":393},"  \u002F\u002F Десериализуем объект пользователя, если валидация прошла успешно\n",[79,741,743,746],{"class":81,"line":742},54,[79,744,745],{"class":85},"  try",[79,747,120],{"class":89},[79,749,751,754,757,759,761,763,765,768],{"class":81,"line":750},55,[79,752,753],{"class":85},"    const",[79,755,756],{"class":133}," userRaw",[79,758,307],{"class":85},[79,760,329],{"class":89},[79,762,332],{"class":116},[79,764,269],{"class":89},[79,766,767],{"class":96},"'user'",[79,769,340],{"class":89},[79,771,773,775,778,780,782,784,787,790,793,795,798,801,803,806],{"class":81,"line":772},56,[79,774,753],{"class":85},[79,776,777],{"class":133}," user",[79,779,130],{"class":85},[79,781,117],{"class":116},[79,783,307],{"class":85},[79,785,786],{"class":89}," userRaw ",[79,788,789],{"class":85},"?",[79,791,792],{"class":133}," JSON",[79,794,60],{"class":89},[79,796,797],{"class":116},"parse",[79,799,800],{"class":89},"(userRaw) ",[79,802,130],{"class":85},[79,804,805],{"class":133}," undefined",[79,807,100],{"class":89},[79,809,811,813,815,818],{"class":81,"line":810},57,[79,812,367],{"class":85},[79,814,370],{"class":89},[79,816,817],{"class":133},"true",[79,819,820],{"class":89},", user };\n",[79,822,824,827,830],{"class":81,"line":823},58,[79,825,826],{"class":89},"  } ",[79,828,829],{"class":85},"catch",[79,831,832],{"class":89}," (error) {\n",[79,834,836,838,840,842],{"class":81,"line":835},59,[79,837,367],{"class":85},[79,839,370],{"class":89},[79,841,817],{"class":133},[79,843,376],{"class":89},[79,845,847],{"class":81,"line":846},60,[79,848,382],{"class":89},[79,850,852],{"class":81,"line":851},61,[79,853,205],{"class":89},[26,855,857],{"id":856},"_2-интеграция-во-фронтенд-и-адаптация-темы-оформления","2. Интеграция во фронтенд и адаптация темы оформления",[11,859,860],{},"Для создания премиального интерфейса (UI\u002FUX) приложение должно подстраиваться под цветовую гамму клиента Telegram (темная\u002Fсветлая тема). Доступ к этим параметрам осуществляется через библиотеку JS-моста.",[862,863,865],"h3",{"id":864},"шаг-1-подключение-скрипта-в-nuxt-3-html","Шаг 1: Подключение скрипта в Nuxt 3 \u002F HTML",[11,867,868,869,130],{},"Добавьте скрипт в секцию head в Nuxt с помощью composable ",[42,870,871],{},"useHead",[34,873,875],{"className":73,"code":874,"language":75,"meta":40,"style":40},"\u002F\u002F app.vue или layout страницы\nuseHead({\n  script: [\n    { src: 'https:\u002F\u002Ftelegram.org\u002Fjs\u002Ftelegram-web-app.js', defer: true }\n  ]\n})\n",[42,876,877,882,889,894,910,915],{"__ignoreMap":40},[79,878,879],{"class":81,"line":82},[79,880,881],{"class":393},"\u002F\u002F app.vue или layout страницы\n",[79,883,884,886],{"class":81,"line":103},[79,885,871],{"class":116},[79,887,888],{"class":89},"({\n",[79,890,891],{"class":81,"line":110},[79,892,893],{"class":89},"  script: [\n",[79,895,896,899,902,905,907],{"class":81,"line":123},[79,897,898],{"class":89},"    { src: ",[79,900,901],{"class":96},"'https:\u002F\u002Ftelegram.org\u002Fjs\u002Ftelegram-web-app.js'",[79,903,904],{"class":89},", defer: ",[79,906,817],{"class":133},[79,908,909],{"class":89}," }\n",[79,911,912],{"class":81,"line":139},[79,913,914],{"class":89},"  ]\n",[79,916,917],{"class":81,"line":152},[79,918,919],{"class":89},"})\n",[862,921,923],{"id":922},"шаг-2-использование-js-моста-в-vue-компонентах","Шаг 2: Использование JS-моста в Vue-компонентах",[11,925,926],{},"Создадим реактивный composable для считывания настроек Telegram:",[34,928,930],{"className":73,"code":929,"language":75,"meta":40,"style":40},"\u002F\u002F composables\u002FuseTelegram.ts\nimport { ref, onMounted } from 'vue';\n\nexport function useTelegram() {\n  const isReady = ref(false);\n  const user = ref\u003Cany>(null);\n\n  onMounted(() => {\n    const tg = (window as any).Telegram?.WebApp;\n    if (tg) {\n      tg.ready();\n      tg.expand(); \u002F\u002F Разворачиваем окно на максимальную высоту\n      \n      user.value = tg.initDataUnsafe?.user;\n      isReady.value = true;\n\n      \u002F\u002F Передаем цвета темы Telegram в переменные CSS (Custom Properties)\n      const root = document.documentElement;\n      root.style.setProperty('--color-tg-bg', tg.themeParams.bg_color);\n      root.style.setProperty('--color-tg-text', tg.themeParams.text_color);\n      root.style.setProperty('--color-tg-button', tg.themeParams.button_color);\n      root.style.setProperty('--color-tg-button-text', tg.themeParams.button_text_color);\n    }\n  });\n\n  return { isReady, user };\n}\n",[42,931,932,937,951,955,967,985,1009,1013,1026,1047,1055,1065,1078,1083,1094,1106,1110,1115,1128,1144,1158,1172,1186,1191,1196,1200,1208],{"__ignoreMap":40},[79,933,934],{"class":81,"line":82},[79,935,936],{"class":393},"\u002F\u002F composables\u002FuseTelegram.ts\n",[79,938,939,941,944,946,949],{"class":81,"line":103},[79,940,86],{"class":85},[79,942,943],{"class":89}," { ref, onMounted } ",[79,945,93],{"class":85},[79,947,948],{"class":96}," 'vue'",[79,950,100],{"class":89},[79,952,953],{"class":81,"line":110},[79,954,107],{"emptyLinePlaceholder":106},[79,956,957,959,961,964],{"class":81,"line":123},[79,958,260],{"class":85},[79,960,263],{"class":85},[79,962,963],{"class":116}," useTelegram",[79,965,966],{"class":89},"() {\n",[79,968,969,971,974,976,979,981,983],{"class":81,"line":139},[79,970,301],{"class":85},[79,972,973],{"class":133}," isReady",[79,975,307],{"class":85},[79,977,978],{"class":116}," ref",[79,980,269],{"class":89},[79,982,373],{"class":133},[79,984,340],{"class":89},[79,986,987,989,991,993,995,998,1001,1004,1007],{"class":81,"line":152},[79,988,301],{"class":85},[79,990,777],{"class":133},[79,992,307],{"class":85},[79,994,978],{"class":116},[79,996,997],{"class":89},"\u003C",[79,999,1000],{"class":133},"any",[79,1002,1003],{"class":89},">(",[79,1005,1006],{"class":133},"null",[79,1008,340],{"class":89},[79,1010,1011],{"class":81,"line":165},[79,1012,107],{"emptyLinePlaceholder":106},[79,1014,1015,1018,1021,1024],{"class":81,"line":177},[79,1016,1017],{"class":116},"  onMounted",[79,1019,1020],{"class":89},"(() ",[79,1022,1023],{"class":85},"=>",[79,1025,120],{"class":89},[79,1027,1028,1030,1033,1035,1038,1041,1044],{"class":81,"line":189},[79,1029,753],{"class":85},[79,1031,1032],{"class":133}," tg",[79,1034,307],{"class":85},[79,1036,1037],{"class":89}," (window ",[79,1039,1040],{"class":85},"as",[79,1042,1043],{"class":133}," any",[79,1045,1046],{"class":89},").Telegram?.WebApp;\n",[79,1048,1049,1052],{"class":81,"line":202},[79,1050,1051],{"class":85},"    if",[79,1053,1054],{"class":89}," (tg) {\n",[79,1056,1057,1060,1063],{"class":81,"line":208},[79,1058,1059],{"class":89},"      tg.",[79,1061,1062],{"class":116},"ready",[79,1064,447],{"class":89},[79,1066,1067,1069,1072,1075],{"class":81,"line":213},[79,1068,1059],{"class":89},[79,1070,1071],{"class":116},"expand",[79,1073,1074],{"class":89},"(); ",[79,1076,1077],{"class":393},"\u002F\u002F Разворачиваем окно на максимальную высоту\n",[79,1079,1080],{"class":81,"line":223},[79,1081,1082],{"class":89},"      \n",[79,1084,1085,1088,1091],{"class":81,"line":235},[79,1086,1087],{"class":89},"      user.value ",[79,1089,1090],{"class":85},"=",[79,1092,1093],{"class":89}," tg.initDataUnsafe?.user;\n",[79,1095,1096,1099,1101,1104],{"class":81,"line":247},[79,1097,1098],{"class":89},"      isReady.value ",[79,1100,1090],{"class":85},[79,1102,1103],{"class":133}," true",[79,1105,100],{"class":89},[79,1107,1108],{"class":81,"line":252},[79,1109,107],{"emptyLinePlaceholder":106},[79,1111,1112],{"class":81,"line":257},[79,1113,1114],{"class":393},"      \u002F\u002F Передаем цвета темы Telegram в переменные CSS (Custom Properties)\n",[79,1116,1117,1120,1123,1125],{"class":81,"line":298},[79,1118,1119],{"class":85},"      const",[79,1121,1122],{"class":133}," root",[79,1124,307],{"class":85},[79,1126,1127],{"class":89}," document.documentElement;\n",[79,1129,1130,1133,1136,1138,1141],{"class":81,"line":319},[79,1131,1132],{"class":89},"      root.style.",[79,1134,1135],{"class":116},"setProperty",[79,1137,269],{"class":89},[79,1139,1140],{"class":96},"'--color-tg-bg'",[79,1142,1143],{"class":89},", tg.themeParams.bg_color);\n",[79,1145,1146,1148,1150,1152,1155],{"class":81,"line":343},[79,1147,1132],{"class":89},[79,1149,1135],{"class":116},[79,1151,269],{"class":89},[79,1153,1154],{"class":96},"'--color-tg-text'",[79,1156,1157],{"class":89},", tg.themeParams.text_color);\n",[79,1159,1160,1162,1164,1166,1169],{"class":81,"line":349},[79,1161,1132],{"class":89},[79,1163,1135],{"class":116},[79,1165,269],{"class":89},[79,1167,1168],{"class":96},"'--color-tg-button'",[79,1170,1171],{"class":89},", tg.themeParams.button_color);\n",[79,1173,1174,1176,1178,1180,1183],{"class":81,"line":364},[79,1175,1132],{"class":89},[79,1177,1135],{"class":116},[79,1179,269],{"class":89},[79,1181,1182],{"class":96},"'--color-tg-button-text'",[79,1184,1185],{"class":89},", tg.themeParams.button_text_color);\n",[79,1187,1188],{"class":81,"line":379},[79,1189,1190],{"class":89},"    }\n",[79,1192,1193],{"class":81,"line":385},[79,1194,1195],{"class":89},"  });\n",[79,1197,1198],{"class":81,"line":390},[79,1199,107],{"emptyLinePlaceholder":106},[79,1201,1202,1205],{"class":81,"line":397},[79,1203,1204],{"class":85},"  return",[79,1206,1207],{"class":89}," { isReady, user };\n",[79,1209,1210],{"class":81,"line":450},[79,1211,205],{"class":89},[26,1213,1215],{"id":1214},"_3-прием-платежей-через-telegram-stars","3. Прием платежей через Telegram Stars",[11,1217,1218,1219,355,1222,441],{},"При продаже цифровых товаров (подписки на SaaS, баланс лимитов ИИ, инфопродукты) правила Apple\u002FGoogle обязывают использовать внутреннюю валюту мессенджера — ",[15,1220,1221],{},"Telegram Stars",[42,1223,1224],{},"XTR",[11,1226,1227],{},"Алгоритм покупки цифрового товара:",[1229,1230,1231,1238,1250,1256],"ol",{},[1232,1233,1234,1237],"li",{},[15,1235,1236],{},"Создание счета",": TWA запрашивает бэкенд сгенерировать счет на оплату.",[1232,1239,1240,1243,1244,1247,1248,60],{},[15,1241,1242],{},"Отправка инвойса",": Бэкенд делает запрос к Bot API ",[42,1245,1246],{},"sendInvoice"," с валютой ",[42,1249,1224],{},[1232,1251,1252,1255],{},[15,1253,1254],{},"Оплата в клиенте",": Telegram открывает нативный экран оплаты, где пользователь списывает «Звезды».",[1232,1257,1258,1261,1262,1265],{},[15,1259,1260],{},"Синхронизация",": Приходит вебхук ",[42,1263,1264],{},"successful_payment"," на бэкенд бота, после чего лимиты зачисляются в вашей базе данных.",[11,1267,1268,1269,1274,1275,60],{},"В моей платформе конструктора ботов ",[1270,1271,1273],"a",{"href":1272},"\u002Fru\u002Fprojects\u002Ftelego","TeleGo.io"," мы предоставляем именно такие встроенные платежные сценарии, позволяя пользователям создавать свои TWA и сразу принимать Stars. Кроме того, мы поддерживаем гибридные хелпдески поддержки, о настройке которых вы можете узнать в статье ",[1270,1276,1278],{"href":1277},"\u002Fru\u002Fblog\u002Ftelegram-bot-ai-rag-support","Как создать ИИ-бота поддержки с RAG",[11,1280,1281,1282,1286],{},"Для физических товаров и консультаций можно использовать классический биллинг. Читайте мое руководство ",[1270,1283,1285],{"href":1284},"\u002Fru\u002Fblog\u002Fsaas-stripe-billing-integration","Монетизация SaaS и интеграция Stripe"," для ознакомления со скриптами Express\u002FTypeScript.",[26,1288,1290],{"id":1289},"источники-и-документация","Источники и документация",[1292,1293,1294,1303],"ul",{},[1232,1295,1296,1302],{},[1270,1297,1301],{"href":1298,"rel":1299},"https:\u002F\u002Fcore.telegram.org\u002Fbots\u002Fwebapps",[1300],"nofollow","Telegram Mini Apps"," — официальная документация WebApp SDK и initData",[1232,1304,1305,1310],{},[1270,1306,1309],{"href":1307,"rel":1308},"https:\u002F\u002Fcore.telegram.org\u002Fbots\u002Fpayments",[1300],"Bot Payments API"," — счета, Telegram Stars и вебхуки оплат",[11,1312,1313],{},"Сочетание современных веб-фронтендов с API Telegram WebApp позволяет создавать удобные B2B-продукты внутри популярного мессенджера, минуя сложные регистрационные формы.",[11,1315,1316,1317,1321,1322,1326],{},"Если вы планируете разработку Telegram Mini App с платежами Stars\u002FStripe и динамической темой оформления, ознакомьтесь с моей услугой ",[1270,1318,1320],{"href":1319},"\u002Fru\u002Ftelegram-bots","Создание Telegram-ботов"," или запишитесь на ",[1270,1323,1325],{"href":1324},"\u002Fru\u002Fconsultations","Консультацию",", чтобы разработать архитектуру.",[1328,1329,1330],"style",{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":40,"searchDepth":103,"depth":103,"links":1332},[1333,1334,1336,1340,1341],{"id":28,"depth":103,"text":29},{"id":46,"depth":103,"text":1335},"1. Безопасность: Валидация строки initData",{"id":856,"depth":103,"text":857,"children":1337},[1338,1339],{"id":864,"depth":110,"text":865},{"id":922,"depth":110,"text":923},{"id":1214,"depth":103,"text":1215},{"id":1289,"depth":103,"text":1290},"2026-07-05","Детальное руководство для разработчиков по использованию Telegram Mini Apps в качестве веб-фронтенда, криптографической валидации данных и приему платежей.","md",[1346,1349,1352],{"q":1347,"a":1348},"Чем Telegram Mini App отличается от обычного бота?","Бот общается сообщениями и кнопками, а Mini App открывает внутри Telegram полноценный веб-интерфейс: дашборды, каталоги, формы, графики. Это SPA-приложение в WebView со своим фронтендом, при этом авторизация происходит автоматически через аккаунт Telegram.",{"q":1350,"a":1351},"Как проверить, что данные пользователя в Mini App не подделаны?","Через криптографическую валидацию initData: Telegram подписывает данные пользователя HMAC-SHA256-подписью на основе токена бота. Бэкенд обязан пересчитать и сверить подпись при каждом запросе — без этого любой может выдать себя за другого пользователя.",{"q":1353,"a":1354},"Когда обязательны Telegram Stars, а когда можно Stripe?","Цифровые товары и подписки внутри Mini App по правилам Apple и Google оплачиваются через Telegram Stars. Физические товары, услуги и консультации можно продавать через классические шлюзы вроде Stripe.","\u002Fimages\u002Fblog\u002Fblog_telegram_twa.jpg","ru",{},"\u002Fblog\u002Fru\u002Ftelegram-web-apps-sells",{"title":6,"description":1343},"blog\u002Fru\u002Ftelegram-web-apps-sells",[1362,1363,1364,1365,1366],"Telegram","Mini Apps","SaaS","Node.js","Frontend","YuRgxnAaTWzdgHfj4TSFOXTRw0OPRN5jjGwHaAdmD4k",{"id":1369,"title":1370,"body":1371,"date":2712,"description":2713,"extension":1344,"faq":2714,"image":2724,"lang":1356,"meta":2725,"navigation":106,"path":2726,"published":106,"readTime":213,"seo":2727,"stem":2728,"tags":2729,"updated":1342,"__hash__":2733},"blog\u002Fblog\u002Fru\u002Fsaas-stripe-billing-integration.md","Монетизация SaaS: Как безболезненно настроить Stripe, подписки и биллинг",{"type":8,"value":1372,"toc":2706},[1373,1376,1379,1382,1386,1389,1395,1426,1430,1444,1447,2620,2624,2648,2658,2660,2686,2693,2703],[11,1374,1375],{},"Интеграция рекуррентных платежей в SaaS-продукты обманчиво проста на словах. Но если разовое списание средств с карты реализуется быстро, то управление жизненным циклом подписок вскрывает десятки подводных камней: отслеживание истечения срока действия карт, интервалы повторных попыток списания, апгрейды и даунгрейды тарифных планов, льготные периоды (grace periods) и обработка вебхуков.",[11,1377,1378],{},"Если вебхук от платежной системы обработан некорректно или не в том порядке, вы рискуете заблокировать платящих клиентов либо предоставить бесплатный доступ тем, кто уже отменил подписку.",[11,1380,1381],{},"В этом практическом руководстве мы настроим отказоустойчивый обработчик Stripe Webhooks с проверкой подписи на Node.js\u002FTypeScript и опишем процесс синхронизации данных.",[26,1383,1385],{"id":1384},"_1-анатомия-жизненного-цикла-подписки","1. Анатомия жизненного цикла подписки",[11,1387,1388],{},"Ваша база данных должна точно отслеживать статус подписки клиента. Stripe использует несколько ключевых статусов, которые вы должны синхронизировать со своей БД:",[34,1390,1393],{"className":1391,"code":1392,"language":39,"meta":40},[37],"               +--------------------------------------+\n               |               trialing               |\n               +------------------+-------------------+\n                                  | (Триал завершен или оформлена оплата)\n                                  v\n+------------------->  +----------------------+\n|                      |        active        | \u003C------------------+\n|                      +----------+-----------+                    |\n| (Карта обновлена,               |                                |\n|  списание успешно)              | (Списание не прошло, Stripe        | (Продление\n|                                 |  повторяет попытки)                |  до окончания\n|                                 v                                |  периода)\n|                      +----------+-----------+                    |\n+---------------------+|       past_due       |                    |\n                       +----------+-----------+                    |\n                                  | (Все попытки списания провалились) |\n                                  v                                |\n                       +----------+-----------+                    |\n                       |       canceled       | -------------------+\n                       +----------------------+\n",[42,1394,1392],{"__ignoreMap":40},[1292,1396,1397,1403,1409,1420],{},[1232,1398,1399,1402],{},[42,1400,1401],{},"trialing",": Бесплатный ознакомительный период. Доступ открыт.",[1232,1404,1405,1408],{},[42,1406,1407],{},"active",": Платеж прошел успешно. Полный доступ.",[1232,1410,1411,1414,1415,1419],{},[42,1412,1413],{},"past_due",": Очередной платеж не прошел. ",[1416,1417,1418],"em",{},"Не блокируйте пользователя сразу",". Переведите аккаунт в предупредительный статус (льготный период от 3 до 7 дней), отключите ресурсоемкие API-методы и отправьте напоминание об обновлении платежных данных.",[1232,1421,1422,1425],{},[42,1423,1424],{},"canceled",": Подписка полностью аннулирована. Доступ закрыт. Для возобновления клиент должен заново привязать карту.",[26,1427,1429],{"id":1428},"_2-реализация-обработчика-stripe-webhooks-с-верификацией-подписи","2. Реализация обработчика Stripe Webhooks с верификацией подписи",[11,1431,1432,1433,1436,1437,1440,1441,60],{},"Stripe отправляет асинхронные уведомления об изменениях статусов через HTTP POST запросы. Поскольку любой злоумышленник может отправить поддельный запрос на ваш эндпоинт ",[42,1434,1435],{},"\u002Fapi\u002Fbilling\u002Fwebhook",", вы ",[15,1438,1439],{},"обязаны валидировать криптографическую подпись"," Stripe в заголовке ",[42,1442,1443],{},"stripe-signature",[11,1445,1446],{},"Пример реализации обработчика на Node.js\u002FTypeScript и Express:",[34,1448,1450],{"className":73,"code":1449,"language":75,"meta":40,"style":40},"import express from 'express';\nimport Stripe from 'stripe';\n\nconst stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {\n  apiVersion: '2023-10-16',\n});\n\nconst app = express();\nconst endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;\n\n\u002F\u002F ВАЖНО: Проверка подписи Stripe требует сырого буфера тела запроса (raw body).\n\u002F\u002F Не применяйте bodyParser.json() к этому маршруту до проверки подписи.\napp.post(\n  '\u002Fapi\u002Fbilling\u002Fwebhook',\n  express.raw({ type: 'application\u002Fjson' }),\n  async (req: express.Request, res: express.Response) => {\n    const sig = req.headers['stripe-signature'];\n    \n    if (!sig) {\n      return res.status(400).send('Отсутствует заголовок stripe-signature.');\n    }\n\n    let event: Stripe.Event;\n\n    try {\n      \u002F\u002F Проверяем подпись вебхука, используя секрет из кабинета Stripe\n      event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);\n    } catch (err: any) {\n      console.error(`[Security Warning] Ошибка проверки подписи: ${err.message}`);\n      return res.status(400).send(`Webhook Error: ${err.message}`);\n    }\n\n    try {\n      switch (event.type) {\n        case 'customer.subscription.created':\n        case 'customer.subscription.updated': {\n          const subscription = event.data.object as Stripe.Subscription;\n          await handleSubscriptionUpdate(subscription);\n          break;\n        }\n        case 'customer.subscription.deleted': {\n          const subscription = event.data.object as Stripe.Subscription;\n          await handleSubscriptionDeletion(subscription);\n          break;\n        }\n        case 'invoice.payment_failed': {\n          const invoice = event.data.object as Stripe.Invoice;\n          await handlePaymentFailure(invoice);\n          break;\n        }\n        default:\n          console.log(`[Stripe Webhook] Пропущено событие: ${event.type}`);\n      }\n\n      res.json({ received: true });\n    } catch (err: any) {\n      console.error(`[Webhook Process Error] Ошибка обработки события: ${err.message}`);\n      res.status(500).send('Internal Server Error');\n    }\n  }\n);\n\nasync function handleSubscriptionUpdate(sub: Stripe.Subscription) {\n  const tenantId = sub.metadata.tenantId;\n  if (!tenantId) {\n    throw new Error(`В метаданных подписки ${sub.id} отсутствует ID клиента (tenantId).`);\n  }\n\n  const status = sub.status; \u002F\u002F 'active', 'trialing', 'past_due' и др.\n  const priceId = sub.items.data[0].price.id;\n  const currentPeriodEnd = new Date(sub.current_period_end * 1000);\n\n  \u002F\u002F Синхронизируем статус в нашей базе данных\n  await db.query(\n    `UPDATE tenants \n     SET subscription_status = $1, stripe_price_id = $2, subscription_period_end = $3\n     WHERE id = $4`,\n    [status, priceId, currentPeriodEnd, tenantId]\n  );\n\n  console.log(`[Billing Sync] Статус клиента ${tenantId} обновлен на: ${status}`);\n}\n\nasync function handleSubscriptionDeletion(sub: Stripe.Subscription) {\n  const tenantId = sub.metadata.tenantId;\n  if (!tenantId) return;\n\n  await db.query(\n    `UPDATE tenants \n     SET subscription_status = 'canceled', stripe_price_id = NULL\n     WHERE id = $1`,\n    [tenantId]\n  );\n\n  console.log(`[Billing Sync] Подписка клиента ${tenantId} аннулирована.`);\n}\n\nasync function handlePaymentFailure(invoice: Stripe.Invoice) {\n  const customerId = invoice.customer as string;\n  \u002F\u002F Находим пользователя по customerId и отправляем предупреждающее письмо\n  console.warn(`[Billing Alert] Ошибка оплаты счета для клиента ${customerId}`);\n}\n",[42,1451,1452,1466,1480,1484,1510,1521,1526,1530,1544,1563,1567,1572,1577,1588,1595,1612,1652,1670,1675,1686,1714,1718,1722,1741,1745,1752,1757,1773,1792,1816,1847,1851,1855,1861,1869,1880,1890,1914,1925,1932,1937,1946,1966,1975,1981,1985,1994,2016,2026,2032,2036,2043,2068,2073,2077,2093,2109,2130,2152,2156,2160,2164,2169,2194,2207,2219,2247,2252,2257,2273,2292,2318,2323,2329,2343,2349,2355,2363,2369,2375,2380,2405,2410,2415,2438,2449,2466,2471,2482,2487,2493,2501,2507,2512,2517,2536,2541,2546,2570,2589,2595,2615],{"__ignoreMap":40},[79,1453,1454,1456,1459,1461,1464],{"class":81,"line":82},[79,1455,86],{"class":85},[79,1457,1458],{"class":89}," express ",[79,1460,93],{"class":85},[79,1462,1463],{"class":96}," 'express'",[79,1465,100],{"class":89},[79,1467,1468,1470,1473,1475,1478],{"class":81,"line":103},[79,1469,86],{"class":85},[79,1471,1472],{"class":89}," Stripe ",[79,1474,93],{"class":85},[79,1476,1477],{"class":96}," 'stripe'",[79,1479,100],{"class":89},[79,1481,1482],{"class":81,"line":110},[79,1483,107],{"emptyLinePlaceholder":106},[79,1485,1486,1489,1492,1494,1496,1499,1502,1505,1507],{"class":81,"line":123},[79,1487,1488],{"class":85},"const",[79,1490,1491],{"class":133}," stripe",[79,1493,307],{"class":85},[79,1495,310],{"class":85},[79,1497,1498],{"class":116}," Stripe",[79,1500,1501],{"class":89},"(process.env.",[79,1503,1504],{"class":133},"STRIPE_SECRET_KEY",[79,1506,358],{"class":85},[79,1508,1509],{"class":89},", {\n",[79,1511,1512,1515,1518],{"class":81,"line":139},[79,1513,1514],{"class":89},"  apiVersion: ",[79,1516,1517],{"class":96},"'2023-10-16'",[79,1519,1520],{"class":89},",\n",[79,1522,1523],{"class":81,"line":152},[79,1524,1525],{"class":89},"});\n",[79,1527,1528],{"class":81,"line":165},[79,1529,107],{"emptyLinePlaceholder":106},[79,1531,1532,1534,1537,1539,1542],{"class":81,"line":177},[79,1533,1488],{"class":85},[79,1535,1536],{"class":133}," app",[79,1538,307],{"class":85},[79,1540,1541],{"class":116}," express",[79,1543,447],{"class":89},[79,1545,1546,1548,1551,1553,1556,1559,1561],{"class":81,"line":189},[79,1547,1488],{"class":85},[79,1549,1550],{"class":133}," endpointSecret",[79,1552,307],{"class":85},[79,1554,1555],{"class":89}," process.env.",[79,1557,1558],{"class":133},"STRIPE_WEBHOOK_SECRET",[79,1560,358],{"class":85},[79,1562,100],{"class":89},[79,1564,1565],{"class":81,"line":202},[79,1566,107],{"emptyLinePlaceholder":106},[79,1568,1569],{"class":81,"line":208},[79,1570,1571],{"class":393},"\u002F\u002F ВАЖНО: Проверка подписи Stripe требует сырого буфера тела запроса (raw body).\n",[79,1573,1574],{"class":81,"line":213},[79,1575,1576],{"class":393},"\u002F\u002F Не применяйте bodyParser.json() к этому маршруту до проверки подписи.\n",[79,1578,1579,1582,1585],{"class":81,"line":223},[79,1580,1581],{"class":89},"app.",[79,1583,1584],{"class":116},"post",[79,1586,1587],{"class":89},"(\n",[79,1589,1590,1593],{"class":81,"line":235},[79,1591,1592],{"class":96},"  '\u002Fapi\u002Fbilling\u002Fwebhook'",[79,1594,1520],{"class":89},[79,1596,1597,1600,1603,1606,1609],{"class":81,"line":247},[79,1598,1599],{"class":89},"  express.",[79,1601,1602],{"class":116},"raw",[79,1604,1605],{"class":89},"({ type: ",[79,1607,1608],{"class":96},"'application\u002Fjson'",[79,1610,1611],{"class":89}," }),\n",[79,1613,1614,1617,1619,1622,1624,1626,1628,1631,1633,1636,1638,1640,1642,1645,1648,1650],{"class":81,"line":252},[79,1615,1616],{"class":85},"  async",[79,1618,355],{"class":89},[79,1620,1621],{"class":126},"req",[79,1623,130],{"class":85},[79,1625,1541],{"class":116},[79,1627,60],{"class":89},[79,1629,1630],{"class":116},"Request",[79,1632,279],{"class":89},[79,1634,1635],{"class":126},"res",[79,1637,130],{"class":85},[79,1639,1541],{"class":116},[79,1641,60],{"class":89},[79,1643,1644],{"class":116},"Response",[79,1646,1647],{"class":89},") ",[79,1649,1023],{"class":85},[79,1651,120],{"class":89},[79,1653,1654,1656,1659,1661,1664,1667],{"class":81,"line":257},[79,1655,753],{"class":85},[79,1657,1658],{"class":133}," sig",[79,1660,307],{"class":85},[79,1662,1663],{"class":89}," req.headers[",[79,1665,1666],{"class":96},"'stripe-signature'",[79,1668,1669],{"class":89},"];\n",[79,1671,1672],{"class":81,"line":298},[79,1673,1674],{"class":89},"    \n",[79,1676,1677,1679,1681,1683],{"class":81,"line":319},[79,1678,1051],{"class":85},[79,1680,355],{"class":89},[79,1682,358],{"class":85},[79,1684,1685],{"class":89},"sig) {\n",[79,1687,1688,1691,1694,1697,1699,1702,1704,1707,1709,1712],{"class":81,"line":343},[79,1689,1690],{"class":85},"      return",[79,1692,1693],{"class":89}," res.",[79,1695,1696],{"class":116},"status",[79,1698,269],{"class":89},[79,1700,1701],{"class":133},"400",[79,1703,441],{"class":89},[79,1705,1706],{"class":116},"send",[79,1708,269],{"class":89},[79,1710,1711],{"class":96},"'Отсутствует заголовок stripe-signature.'",[79,1713,340],{"class":89},[79,1715,1716],{"class":81,"line":349},[79,1717,1190],{"class":89},[79,1719,1720],{"class":81,"line":364},[79,1721,107],{"emptyLinePlaceholder":106},[79,1723,1724,1727,1730,1732,1734,1736,1739],{"class":81,"line":379},[79,1725,1726],{"class":85},"    let",[79,1728,1729],{"class":89}," event",[79,1731,130],{"class":85},[79,1733,1498],{"class":116},[79,1735,60],{"class":89},[79,1737,1738],{"class":116},"Event",[79,1740,100],{"class":89},[79,1742,1743],{"class":81,"line":385},[79,1744,107],{"emptyLinePlaceholder":106},[79,1746,1747,1750],{"class":81,"line":390},[79,1748,1749],{"class":85},"    try",[79,1751,120],{"class":89},[79,1753,1754],{"class":81,"line":397},[79,1755,1756],{"class":393},"      \u002F\u002F Проверяем подпись вебхука, используя секрет из кабинета Stripe\n",[79,1758,1759,1762,1764,1767,1770],{"class":81,"line":450},[79,1760,1761],{"class":89},"      event ",[79,1763,1090],{"class":85},[79,1765,1766],{"class":89}," stripe.webhooks.",[79,1768,1769],{"class":116},"constructEvent",[79,1771,1772],{"class":89},"(req.body, sig, endpointSecret);\n",[79,1774,1775,1778,1780,1782,1785,1787,1789],{"class":81,"line":455},[79,1776,1777],{"class":89},"    } ",[79,1779,829],{"class":85},[79,1781,355],{"class":89},[79,1783,1784],{"class":126},"err",[79,1786,130],{"class":85},[79,1788,1043],{"class":133},[79,1790,1791],{"class":89},") {\n",[79,1793,1794,1797,1800,1802,1805,1807,1809,1812,1814],{"class":81,"line":461},[79,1795,1796],{"class":89},"      console.",[79,1798,1799],{"class":116},"error",[79,1801,269],{"class":89},[79,1803,1804],{"class":96},"`[Security Warning] Ошибка проверки подписи: ${",[79,1806,1784],{"class":89},[79,1808,60],{"class":96},[79,1810,1811],{"class":89},"message",[79,1813,510],{"class":96},[79,1815,340],{"class":89},[79,1817,1818,1820,1822,1824,1826,1828,1830,1832,1834,1837,1839,1841,1843,1845],{"class":81,"line":474},[79,1819,1690],{"class":85},[79,1821,1693],{"class":89},[79,1823,1696],{"class":116},[79,1825,269],{"class":89},[79,1827,1701],{"class":133},[79,1829,441],{"class":89},[79,1831,1706],{"class":116},[79,1833,269],{"class":89},[79,1835,1836],{"class":96},"`Webhook Error: ${",[79,1838,1784],{"class":89},[79,1840,60],{"class":96},[79,1842,1811],{"class":89},[79,1844,510],{"class":96},[79,1846,340],{"class":89},[79,1848,1849],{"class":81,"line":516},[79,1850,1190],{"class":89},[79,1852,1853],{"class":81,"line":536},[79,1854,107],{"emptyLinePlaceholder":106},[79,1856,1857,1859],{"class":81,"line":541},[79,1858,1749],{"class":85},[79,1860,120],{"class":89},[79,1862,1863,1866],{"class":81,"line":547},[79,1864,1865],{"class":85},"      switch",[79,1867,1868],{"class":89}," (event.type) {\n",[79,1870,1871,1874,1877],{"class":81,"line":553},[79,1872,1873],{"class":85},"        case",[79,1875,1876],{"class":96}," 'customer.subscription.created'",[79,1878,1879],{"class":89},":\n",[79,1881,1882,1884,1887],{"class":81,"line":566},[79,1883,1873],{"class":85},[79,1885,1886],{"class":96}," 'customer.subscription.updated'",[79,1888,1889],{"class":89},": {\n",[79,1891,1892,1895,1898,1900,1903,1905,1907,1909,1912],{"class":81,"line":586},[79,1893,1894],{"class":85},"          const",[79,1896,1897],{"class":133}," subscription",[79,1899,307],{"class":85},[79,1901,1902],{"class":89}," event.data.object ",[79,1904,1040],{"class":85},[79,1906,1498],{"class":116},[79,1908,60],{"class":89},[79,1910,1911],{"class":116},"Subscription",[79,1913,100],{"class":89},[79,1915,1916,1919,1922],{"class":81,"line":597},[79,1917,1918],{"class":85},"          await",[79,1920,1921],{"class":116}," handleSubscriptionUpdate",[79,1923,1924],{"class":89},"(subscription);\n",[79,1926,1927,1930],{"class":81,"line":607},[79,1928,1929],{"class":85},"          break",[79,1931,100],{"class":89},[79,1933,1934],{"class":81,"line":612},[79,1935,1936],{"class":89},"        }\n",[79,1938,1939,1941,1944],{"class":81,"line":618},[79,1940,1873],{"class":85},[79,1942,1943],{"class":96}," 'customer.subscription.deleted'",[79,1945,1889],{"class":89},[79,1947,1948,1950,1952,1954,1956,1958,1960,1962,1964],{"class":81,"line":630},[79,1949,1894],{"class":85},[79,1951,1897],{"class":133},[79,1953,307],{"class":85},[79,1955,1902],{"class":89},[79,1957,1040],{"class":85},[79,1959,1498],{"class":116},[79,1961,60],{"class":89},[79,1963,1911],{"class":116},[79,1965,100],{"class":89},[79,1967,1968,1970,1973],{"class":81,"line":644},[79,1969,1918],{"class":85},[79,1971,1972],{"class":116}," handleSubscriptionDeletion",[79,1974,1924],{"class":89},[79,1976,1977,1979],{"class":81,"line":654},[79,1978,1929],{"class":85},[79,1980,100],{"class":89},[79,1982,1983],{"class":81,"line":668},[79,1984,1936],{"class":89},[79,1986,1987,1989,1992],{"class":81,"line":673},[79,1988,1873],{"class":85},[79,1990,1991],{"class":96}," 'invoice.payment_failed'",[79,1993,1889],{"class":89},[79,1995,1996,1998,2001,2003,2005,2007,2009,2011,2014],{"class":81,"line":679},[79,1997,1894],{"class":85},[79,1999,2000],{"class":133}," invoice",[79,2002,307],{"class":85},[79,2004,1902],{"class":89},[79,2006,1040],{"class":85},[79,2008,1498],{"class":116},[79,2010,60],{"class":89},[79,2012,2013],{"class":116},"Invoice",[79,2015,100],{"class":89},[79,2017,2018,2020,2023],{"class":81,"line":698},[79,2019,1918],{"class":85},[79,2021,2022],{"class":116}," handlePaymentFailure",[79,2024,2025],{"class":89},"(invoice);\n",[79,2027,2028,2030],{"class":81,"line":703},[79,2029,1929],{"class":85},[79,2031,100],{"class":89},[79,2033,2034],{"class":81,"line":715},[79,2035,1936],{"class":89},[79,2037,2038,2041],{"class":81,"line":726},[79,2039,2040],{"class":85},"        default",[79,2042,1879],{"class":89},[79,2044,2045,2048,2051,2053,2056,2059,2061,2064,2066],{"class":81,"line":731},[79,2046,2047],{"class":89},"          console.",[79,2049,2050],{"class":116},"log",[79,2052,269],{"class":89},[79,2054,2055],{"class":96},"`[Stripe Webhook] Пропущено событие: ${",[79,2057,2058],{"class":89},"event",[79,2060,60],{"class":96},[79,2062,2063],{"class":89},"type",[79,2065,510],{"class":96},[79,2067,340],{"class":89},[79,2069,2070],{"class":81,"line":736},[79,2071,2072],{"class":89},"      }\n",[79,2074,2075],{"class":81,"line":742},[79,2076,107],{"emptyLinePlaceholder":106},[79,2078,2079,2082,2085,2088,2090],{"class":81,"line":750},[79,2080,2081],{"class":89},"      res.",[79,2083,2084],{"class":116},"json",[79,2086,2087],{"class":89},"({ received: ",[79,2089,817],{"class":133},[79,2091,2092],{"class":89}," });\n",[79,2094,2095,2097,2099,2101,2103,2105,2107],{"class":81,"line":772},[79,2096,1777],{"class":89},[79,2098,829],{"class":85},[79,2100,355],{"class":89},[79,2102,1784],{"class":126},[79,2104,130],{"class":85},[79,2106,1043],{"class":133},[79,2108,1791],{"class":89},[79,2110,2111,2113,2115,2117,2120,2122,2124,2126,2128],{"class":81,"line":810},[79,2112,1796],{"class":89},[79,2114,1799],{"class":116},[79,2116,269],{"class":89},[79,2118,2119],{"class":96},"`[Webhook Process Error] Ошибка обработки события: ${",[79,2121,1784],{"class":89},[79,2123,60],{"class":96},[79,2125,1811],{"class":89},[79,2127,510],{"class":96},[79,2129,340],{"class":89},[79,2131,2132,2134,2136,2138,2141,2143,2145,2147,2150],{"class":81,"line":823},[79,2133,2081],{"class":89},[79,2135,1696],{"class":116},[79,2137,269],{"class":89},[79,2139,2140],{"class":133},"500",[79,2142,441],{"class":89},[79,2144,1706],{"class":116},[79,2146,269],{"class":89},[79,2148,2149],{"class":96},"'Internal Server Error'",[79,2151,340],{"class":89},[79,2153,2154],{"class":81,"line":835},[79,2155,1190],{"class":89},[79,2157,2158],{"class":81,"line":846},[79,2159,382],{"class":89},[79,2161,2162],{"class":81,"line":851},[79,2163,340],{"class":89},[79,2165,2167],{"class":81,"line":2166},62,[79,2168,107],{"emptyLinePlaceholder":106},[79,2170,2172,2175,2177,2179,2181,2184,2186,2188,2190,2192],{"class":81,"line":2171},63,[79,2173,2174],{"class":85},"async",[79,2176,263],{"class":85},[79,2178,1921],{"class":116},[79,2180,269],{"class":89},[79,2182,2183],{"class":126},"sub",[79,2185,130],{"class":85},[79,2187,1498],{"class":116},[79,2189,60],{"class":89},[79,2191,1911],{"class":116},[79,2193,1791],{"class":89},[79,2195,2197,2199,2202,2204],{"class":81,"line":2196},64,[79,2198,301],{"class":85},[79,2200,2201],{"class":133}," tenantId",[79,2203,307],{"class":85},[79,2205,2206],{"class":89}," sub.metadata.tenantId;\n",[79,2208,2210,2212,2214,2216],{"class":81,"line":2209},65,[79,2211,352],{"class":85},[79,2213,355],{"class":89},[79,2215,358],{"class":85},[79,2217,2218],{"class":89},"tenantId) {\n",[79,2220,2222,2225,2227,2230,2232,2235,2237,2239,2242,2245],{"class":81,"line":2221},66,[79,2223,2224],{"class":85},"    throw",[79,2226,310],{"class":85},[79,2228,2229],{"class":116}," Error",[79,2231,269],{"class":89},[79,2233,2234],{"class":96},"`В метаданных подписки ${",[79,2236,2183],{"class":89},[79,2238,60],{"class":96},[79,2240,2241],{"class":89},"id",[79,2243,2244],{"class":96},"} отсутствует ID клиента (tenantId).`",[79,2246,340],{"class":89},[79,2248,2250],{"class":81,"line":2249},67,[79,2251,382],{"class":89},[79,2253,2255],{"class":81,"line":2254},68,[79,2256,107],{"emptyLinePlaceholder":106},[79,2258,2260,2262,2265,2267,2270],{"class":81,"line":2259},69,[79,2261,301],{"class":85},[79,2263,2264],{"class":133}," status",[79,2266,307],{"class":85},[79,2268,2269],{"class":89}," sub.status; ",[79,2271,2272],{"class":393},"\u002F\u002F 'active', 'trialing', 'past_due' и др.\n",[79,2274,2276,2278,2281,2283,2286,2289],{"class":81,"line":2275},70,[79,2277,301],{"class":85},[79,2279,2280],{"class":133}," priceId",[79,2282,307],{"class":85},[79,2284,2285],{"class":89}," sub.items.data[",[79,2287,2288],{"class":133},"0",[79,2290,2291],{"class":89},"].price.id;\n",[79,2293,2295,2297,2300,2302,2304,2307,2310,2313,2316],{"class":81,"line":2294},71,[79,2296,301],{"class":85},[79,2298,2299],{"class":133}," currentPeriodEnd",[79,2301,307],{"class":85},[79,2303,310],{"class":85},[79,2305,2306],{"class":116}," Date",[79,2308,2309],{"class":89},"(sub.current_period_end ",[79,2311,2312],{"class":85},"*",[79,2314,2315],{"class":133}," 1000",[79,2317,340],{"class":89},[79,2319,2321],{"class":81,"line":2320},72,[79,2322,107],{"emptyLinePlaceholder":106},[79,2324,2326],{"class":81,"line":2325},73,[79,2327,2328],{"class":393},"  \u002F\u002F Синхронизируем статус в нашей базе данных\n",[79,2330,2332,2335,2338,2341],{"class":81,"line":2331},74,[79,2333,2334],{"class":85},"  await",[79,2336,2337],{"class":89}," db.",[79,2339,2340],{"class":116},"query",[79,2342,1587],{"class":89},[79,2344,2346],{"class":81,"line":2345},75,[79,2347,2348],{"class":96},"    `UPDATE tenants \n",[79,2350,2352],{"class":81,"line":2351},76,[79,2353,2354],{"class":96},"     SET subscription_status = $1, stripe_price_id = $2, subscription_period_end = $3\n",[79,2356,2358,2361],{"class":81,"line":2357},77,[79,2359,2360],{"class":96},"     WHERE id = $4`",[79,2362,1520],{"class":89},[79,2364,2366],{"class":81,"line":2365},78,[79,2367,2368],{"class":89},"    [status, priceId, currentPeriodEnd, tenantId]\n",[79,2370,2372],{"class":81,"line":2371},79,[79,2373,2374],{"class":89},"  );\n",[79,2376,2378],{"class":81,"line":2377},80,[79,2379,107],{"emptyLinePlaceholder":106},[79,2381,2383,2386,2388,2390,2393,2396,2399,2401,2403],{"class":81,"line":2382},81,[79,2384,2385],{"class":89},"  console.",[79,2387,2050],{"class":116},[79,2389,269],{"class":89},[79,2391,2392],{"class":96},"`[Billing Sync] Статус клиента ${",[79,2394,2395],{"class":89},"tenantId",[79,2397,2398],{"class":96},"} обновлен на: ${",[79,2400,1696],{"class":89},[79,2402,510],{"class":96},[79,2404,340],{"class":89},[79,2406,2408],{"class":81,"line":2407},82,[79,2409,205],{"class":89},[79,2411,2413],{"class":81,"line":2412},83,[79,2414,107],{"emptyLinePlaceholder":106},[79,2416,2418,2420,2422,2424,2426,2428,2430,2432,2434,2436],{"class":81,"line":2417},84,[79,2419,2174],{"class":85},[79,2421,263],{"class":85},[79,2423,1972],{"class":116},[79,2425,269],{"class":89},[79,2427,2183],{"class":126},[79,2429,130],{"class":85},[79,2431,1498],{"class":116},[79,2433,60],{"class":89},[79,2435,1911],{"class":116},[79,2437,1791],{"class":89},[79,2439,2441,2443,2445,2447],{"class":81,"line":2440},85,[79,2442,301],{"class":85},[79,2444,2201],{"class":133},[79,2446,307],{"class":85},[79,2448,2206],{"class":89},[79,2450,2452,2454,2456,2458,2461,2464],{"class":81,"line":2451},86,[79,2453,352],{"class":85},[79,2455,355],{"class":89},[79,2457,358],{"class":85},[79,2459,2460],{"class":89},"tenantId) ",[79,2462,2463],{"class":85},"return",[79,2465,100],{"class":89},[79,2467,2469],{"class":81,"line":2468},87,[79,2470,107],{"emptyLinePlaceholder":106},[79,2472,2474,2476,2478,2480],{"class":81,"line":2473},88,[79,2475,2334],{"class":85},[79,2477,2337],{"class":89},[79,2479,2340],{"class":116},[79,2481,1587],{"class":89},[79,2483,2485],{"class":81,"line":2484},89,[79,2486,2348],{"class":96},[79,2488,2490],{"class":81,"line":2489},90,[79,2491,2492],{"class":96},"     SET subscription_status = 'canceled', stripe_price_id = NULL\n",[79,2494,2496,2499],{"class":81,"line":2495},91,[79,2497,2498],{"class":96},"     WHERE id = $1`",[79,2500,1520],{"class":89},[79,2502,2504],{"class":81,"line":2503},92,[79,2505,2506],{"class":89},"    [tenantId]\n",[79,2508,2510],{"class":81,"line":2509},93,[79,2511,2374],{"class":89},[79,2513,2515],{"class":81,"line":2514},94,[79,2516,107],{"emptyLinePlaceholder":106},[79,2518,2520,2522,2524,2526,2529,2531,2534],{"class":81,"line":2519},95,[79,2521,2385],{"class":89},[79,2523,2050],{"class":116},[79,2525,269],{"class":89},[79,2527,2528],{"class":96},"`[Billing Sync] Подписка клиента ${",[79,2530,2395],{"class":89},[79,2532,2533],{"class":96},"} аннулирована.`",[79,2535,340],{"class":89},[79,2537,2539],{"class":81,"line":2538},96,[79,2540,205],{"class":89},[79,2542,2544],{"class":81,"line":2543},97,[79,2545,107],{"emptyLinePlaceholder":106},[79,2547,2549,2551,2553,2555,2557,2560,2562,2564,2566,2568],{"class":81,"line":2548},98,[79,2550,2174],{"class":85},[79,2552,263],{"class":85},[79,2554,2022],{"class":116},[79,2556,269],{"class":89},[79,2558,2559],{"class":126},"invoice",[79,2561,130],{"class":85},[79,2563,1498],{"class":116},[79,2565,60],{"class":89},[79,2567,2013],{"class":116},[79,2569,1791],{"class":89},[79,2571,2573,2575,2578,2580,2583,2585,2587],{"class":81,"line":2572},99,[79,2574,301],{"class":85},[79,2576,2577],{"class":133}," customerId",[79,2579,307],{"class":85},[79,2581,2582],{"class":89}," invoice.customer ",[79,2584,1040],{"class":85},[79,2586,147],{"class":133},[79,2588,100],{"class":89},[79,2590,2592],{"class":81,"line":2591},100,[79,2593,2594],{"class":393},"  \u002F\u002F Находим пользователя по customerId и отправляем предупреждающее письмо\n",[79,2596,2598,2600,2603,2605,2608,2611,2613],{"class":81,"line":2597},101,[79,2599,2385],{"class":89},[79,2601,2602],{"class":116},"warn",[79,2604,269],{"class":89},[79,2606,2607],{"class":96},"`[Billing Alert] Ошибка оплаты счета для клиента ${",[79,2609,2610],{"class":89},"customerId",[79,2612,510],{"class":96},[79,2614,340],{"class":89},[79,2616,2618],{"class":81,"line":2617},102,[79,2619,205],{"class":89},[26,2621,2623],{"id":2622},"_3-рекомендации-по-интеграции-биллинга-в-saas","3. Рекомендации по интеграции биллинга в SaaS",[1229,2625,2626,2632,2642],{},[1232,2627,2628,2631],{},[15,2629,2630],{},"Stripe Checkout и Customer Portal спасают недели работы",": Не тратьте ресурсы на создание дашбордов привязки карт, просмотра истории инвойсов и отмены услуг. Готовые решения Stripe безопасны, соответствуют PCI DSS и переведены на десятки языков.",[1232,2633,2634,2637,2638,2641],{},[15,2635,2636],{},"Используйте Metadata",": Всегда передавайте ID сущностей вашей базы данных в поле ",[42,2639,2640],{},"metadata"," при создании платежных сессий. Это единственный надежный способ сопоставить событие вебхука с конкретной строкой в вашей БД.",[1232,2643,2644,2647],{},[15,2645,2646],{},"Защита от дублирования (Идемпотентность)",": Вебхуки могут приходить повторно. Убедитесь, что ваш обработчик проверяет, не был ли статус уже записан, чтобы исключить лишние операции отправки писем или пересчета лимитов.",[11,2649,2650,2651,2653,2654,60],{},"Например, в проекте ",[1270,2652,1273],{"href":1272}," мы внедрили именно эту архитектуру верификации вебхуков для безопасного управления подписками. Мы также совместили её с микротранзакциями Telegram Stars, о чем вы можете прочитать в моей статье ",[1270,2655,2657],{"href":2656},"\u002Fru\u002Fblog\u002Ftelegram-web-apps-sells","Разработка SaaS внутри Telegram Web Apps",[26,2659,1290],{"id":1289},[1292,2661,2662,2670,2678],{},[1232,2663,2664,2669],{},[1270,2665,2668],{"href":2666,"rel":2667},"https:\u002F\u002Fdocs.stripe.com\u002Fwebhooks",[1300],"Stripe Webhooks"," — доставка, подпись и повторные попытки событий",[1232,2671,2672,2677],{},[1270,2673,2676],{"href":2674,"rel":2675},"https:\u002F\u002Fdocs.stripe.com\u002Fbilling\u002Fsubscriptions\u002Foverview",[1300],"Stripe: жизненный цикл подписок"," — все статусы из схемы выше",[1232,2679,2680,2685],{},[1270,2681,2684],{"href":2682,"rel":2683},"https:\u002F\u002Fdocs.stripe.com\u002Fstripe-cli",[1300],"Stripe CLI"," — локальное тестирование вебхуков",[11,2687,2688,2689,60],{},"Профессиональная платежная архитектура защищает доходы вашего SaaS-бизнеса и гарантирует высокий уровень лояльности платящих клиентов. Чтобы узнать, как биллинг встраивается в общий цикл быстрого запуска стартапа, ознакомьтесь с моим руководством ",[1270,2690,2692],{"href":2691},"\u002Fru\u002Fblog\u002Fb2b-saas-mvp-30-days","Как собрать B2B SaaS за 30 дней",[11,2694,2695,2696,1321,2700,2702],{},"Если вы планируете настроить прием платежей на своем проекте или провести рефакторинг текущей платежной системы, изучите мою услугу ",[1270,2697,2699],{"href":2698},"\u002Fru\u002Fsaas-development","Разработки SaaS-платформ",[1270,2701,1325],{"href":1324},", чтобы разработать надежный биллинг.",[1328,2704,2705],{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":40,"searchDepth":103,"depth":103,"links":2707},[2708,2709,2710,2711],{"id":1384,"depth":103,"text":1385},{"id":1428,"depth":103,"text":1429},{"id":2622,"depth":103,"text":2623},{"id":1289,"depth":103,"text":1290},"2026-06-14","Подробное техническое руководство по интеграции рекуррентных платежей Stripe в B2B SaaS-приложения, валидации подписи вебхуков и управлению льготными периодами.",[2715,2718,2721],{"q":2716,"a":2717},"Почему нельзя выдавать доступ сразу после редиректа с оплаты?","Редирект на success-страницу не гарантирует, что деньги списаны: пользователь мог закрыть вкладку, а платёж — не пройти антифрод. Единственный источник истины — вебхуки Stripe: доступ выдаётся по событию checkout.session.completed, а продлевается по invoice.paid.",{"q":2719,"a":2720},"Что делать, если вебхук от Stripe не дошёл?","Stripe автоматически повторяет доставку событий до трёх суток, поэтому обработчик обязан быть идемпотентным — повторное событие не должно дублировать письма или продления. Для страховки добавляют фоновую сверку статусов подписок по расписанию.",{"q":2722,"a":2723},"Как тестировать вебхуки локально?","Через Stripe CLI: команда stripe listen пробрасывает события на localhost, а stripe trigger генерирует тестовые события любого типа — от успешной оплаты до провала списания. Это позволяет отладить все ветки обработчика до продакшена.","\u002Fimages\u002Fblog\u002Fblog_stripe_billing.jpg",{},"\u002Fblog\u002Fru\u002Fsaas-stripe-billing-integration",{"title":1370,"description":2713},"blog\u002Fru\u002Fsaas-stripe-billing-integration",[1364,2730,1365,2731,2732],"Платежи","API","Stripe","VV1Hk94TSww6REieAjuXB7iyyNloDJZ0IN_UJQh4rt8",1784561425119]