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