[{"data":1,"prerenderedAt":11464},["ShallowReactive",2],{"header-latest":3,"all-posts":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",[1373,2370,2961,3589,3840,4508,4812,6160,6870,7555,8982,9283,9556,9825,11188],{"id":5,"title":6,"body":1374,"date":1346,"description":1347,"extension":1348,"faq":2363,"image":1359,"lang":1360,"meta":2367,"navigation":108,"path":1362,"published":108,"readTime":215,"seo":2368,"stem":1364,"tags":2369,"updated":1346,"__hash__":1371},{"type":8,"value":1375,"toc":2353},[1376,1380,1382,1384,1386,1388,1393,1397,1403,1407,1409,1999,2001,2003,2005,2009,2047,2049,2051,2285,2287,2293,2295,2319,2325,2329,2331,2343,2345,2351],[11,1377,13,1378,18],{},[15,1379,17],{},[11,1381,21],{},[11,1383,24],{},[26,1385,29],{"id":28},[11,1387,32],{},[34,1389,1391],{"className":1390,"code":38,"language":39,"meta":40},[37],[42,1392,38],{"__ignoreMap":40},[26,1394,47,1395,51],{"id":46},[42,1396,50],{},[11,1398,54,1399,58,1401,62],{},[42,1400,57],{},[42,1402,61],{},[11,1404,65,1405,69],{},[15,1406,68],{},[11,1408,72],{},[34,1410,1411],{"className":75,"code":76,"language":77,"meta":40,"style":40},[42,1412,1413,1425,1429,1437,1447,1457,1467,1477,1487,1497,1501,1505,1513,1523,1533,1537,1541,1573,1587,1605,1609,1619,1629,1633,1637,1641,1679,1683,1687,1697,1731,1747,1751,1755,1759,1769,1785,1793,1801,1805,1809,1819,1831,1839,1851,1855,1859,1873,1877,1887,1897,1901,1905,1909,1915,1933,1963,1973,1981,1991,1995],{"__ignoreMap":40},[81,1414,1415,1417,1419,1421,1423],{"class":83,"line":84},[81,1416,88],{"class":87},[81,1418,92],{"class":91},[81,1420,95],{"class":87},[81,1422,99],{"class":98},[81,1424,102],{"class":91},[81,1426,1427],{"class":83,"line":105},[81,1428,109],{"emptyLinePlaceholder":108},[81,1430,1431,1433,1435],{"class":83,"line":112},[81,1432,115],{"class":87},[81,1434,119],{"class":118},[81,1436,122],{"class":91},[81,1438,1439,1441,1443,1445],{"class":83,"line":125},[81,1440,129],{"class":128},[81,1442,132],{"class":87},[81,1444,136],{"class":135},[81,1446,102],{"class":91},[81,1448,1449,1451,1453,1455],{"class":83,"line":141},[81,1450,144],{"class":128},[81,1452,132],{"class":87},[81,1454,149],{"class":135},[81,1456,102],{"class":91},[81,1458,1459,1461,1463,1465],{"class":83,"line":154},[81,1460,157],{"class":128},[81,1462,160],{"class":87},[81,1464,149],{"class":135},[81,1466,102],{"class":91},[81,1468,1469,1471,1473,1475],{"class":83,"line":167},[81,1470,170],{"class":128},[81,1472,160],{"class":87},[81,1474,149],{"class":135},[81,1476,102],{"class":91},[81,1478,1479,1481,1483,1485],{"class":83,"line":179},[81,1480,182],{"class":128},[81,1482,160],{"class":87},[81,1484,149],{"class":135},[81,1486,102],{"class":91},[81,1488,1489,1491,1493,1495],{"class":83,"line":191},[81,1490,194],{"class":128},[81,1492,160],{"class":87},[81,1494,199],{"class":135},[81,1496,102],{"class":91},[81,1498,1499],{"class":83,"line":204},[81,1500,207],{"class":91},[81,1502,1503],{"class":83,"line":210},[81,1504,109],{"emptyLinePlaceholder":108},[81,1506,1507,1509,1511],{"class":83,"line":215},[81,1508,115],{"class":87},[81,1510,220],{"class":118},[81,1512,122],{"class":91},[81,1514,1515,1517,1519,1521],{"class":83,"line":225},[81,1516,228],{"class":128},[81,1518,132],{"class":87},[81,1520,199],{"class":135},[81,1522,102],{"class":91},[81,1524,1525,1527,1529,1531],{"class":83,"line":237},[81,1526,240],{"class":128},[81,1528,160],{"class":87},[81,1530,119],{"class":118},[81,1532,102],{"class":91},[81,1534,1535],{"class":83,"line":249},[81,1536,207],{"class":91},[81,1538,1539],{"class":83,"line":254},[81,1540,109],{"emptyLinePlaceholder":108},[81,1542,1543,1545,1547,1549,1551,1553,1555,1557,1559,1561,1563,1565,1567,1569,1571],{"class":83,"line":259},[81,1544,262],{"class":87},[81,1546,265],{"class":87},[81,1548,268],{"class":118},[81,1550,271],{"class":91},[81,1552,274],{"class":128},[81,1554,132],{"class":87},[81,1556,149],{"class":135},[81,1558,281],{"class":91},[81,1560,284],{"class":128},[81,1562,132],{"class":87},[81,1564,149],{"class":135},[81,1566,291],{"class":91},[81,1568,132],{"class":87},[81,1570,220],{"class":118},[81,1572,122],{"class":91},[81,1574,1575,1577,1579,1581,1583,1585],{"class":83,"line":300},[81,1576,303],{"class":87},[81,1578,306],{"class":135},[81,1580,309],{"class":87},[81,1582,312],{"class":87},[81,1584,315],{"class":118},[81,1586,318],{"class":91},[81,1588,1589,1591,1593,1595,1597,1599,1601,1603],{"class":83,"line":321},[81,1590,303],{"class":87},[81,1592,326],{"class":135},[81,1594,309],{"class":87},[81,1596,331],{"class":91},[81,1598,334],{"class":118},[81,1600,271],{"class":91},[81,1602,339],{"class":98},[81,1604,342],{"class":91},[81,1606,1607],{"class":83,"line":345},[81,1608,348],{"class":91},[81,1610,1611,1613,1615,1617],{"class":83,"line":351},[81,1612,354],{"class":87},[81,1614,357],{"class":91},[81,1616,360],{"class":87},[81,1618,363],{"class":91},[81,1620,1621,1623,1625,1627],{"class":83,"line":366},[81,1622,369],{"class":87},[81,1624,372],{"class":91},[81,1626,375],{"class":135},[81,1628,378],{"class":91},[81,1630,1631],{"class":83,"line":381},[81,1632,384],{"class":91},[81,1634,1635],{"class":83,"line":387},[81,1636,109],{"emptyLinePlaceholder":108},[81,1638,1639],{"class":83,"line":392},[81,1640,396],{"class":395},[81,1642,1643,1645,1647,1649,1651,1653,1655,1657,1659,1661,1663,1665,1667,1669,1671,1673,1675,1677],{"class":83,"line":399},[81,1644,303],{"class":87},[81,1646,404],{"class":135},[81,1648,309],{"class":87},[81,1650,409],{"class":91},[81,1652,95],{"class":118},[81,1654,414],{"class":91},[81,1656,417],{"class":118},[81,1658,420],{"class":91},[81,1660,423],{"class":118},[81,1662,271],{"class":91},[81,1664,428],{"class":128},[81,1666,431],{"class":87},[81,1668,434],{"class":91},[81,1670,437],{"class":87},[81,1672,440],{"class":98},[81,1674,443],{"class":91},[81,1676,446],{"class":118},[81,1678,449],{"class":91},[81,1680,1681],{"class":83,"line":452},[81,1682,348],{"class":91},[81,1684,1685],{"class":83,"line":457},[81,1686,460],{"class":395},[81,1688,1689,1691,1693,1695],{"class":83,"line":463},[81,1690,303],{"class":87},[81,1692,468],{"class":135},[81,1694,309],{"class":87},[81,1696,473],{"class":91},[81,1698,1699,1701,1703,1705,1707,1709,1711,1713,1715,1717,1719,1721,1723,1725,1727,1729],{"class":83,"line":476},[81,1700,479],{"class":91},[81,1702,482],{"class":118},[81,1704,271],{"class":91},[81,1706,428],{"class":128},[81,1708,431],{"class":87},[81,1710,491],{"class":98},[81,1712,428],{"class":91},[81,1714,496],{"class":98},[81,1716,499],{"class":91},[81,1718,62],{"class":98},[81,1720,334],{"class":118},[81,1722,271],{"class":98},[81,1724,428],{"class":91},[81,1726,291],{"class":98},[81,1728,512],{"class":98},[81,1730,515],{"class":91},[81,1732,1733,1735,1737,1739,1741,1743,1745],{"class":83,"line":518},[81,1734,479],{"class":91},[81,1736,523],{"class":118},[81,1738,271],{"class":91},[81,1740,528],{"class":98},[81,1742,531],{"class":135},[81,1744,528],{"class":98},[81,1746,342],{"class":91},[81,1748,1749],{"class":83,"line":538},[81,1750,109],{"emptyLinePlaceholder":108},[81,1752,1753],{"class":83,"line":543},[81,1754,546],{"class":395},[81,1756,1757],{"class":83,"line":549},[81,1758,552],{"class":395},[81,1760,1761,1763,1765,1767],{"class":83,"line":555},[81,1762,303],{"class":87},[81,1764,560],{"class":135},[81,1766,309],{"class":87},[81,1768,565],{"class":91},[81,1770,1771,1773,1775,1777,1779,1781,1783],{"class":83,"line":568},[81,1772,479],{"class":91},[81,1774,573],{"class":118},[81,1776,271],{"class":91},[81,1778,578],{"class":98},[81,1780,281],{"class":91},[81,1782,583],{"class":98},[81,1784,515],{"class":91},[81,1786,1787,1789,1791],{"class":83,"line":588},[81,1788,479],{"class":91},[81,1790,593],{"class":118},[81,1792,596],{"class":91},[81,1794,1795,1797,1799],{"class":83,"line":599},[81,1796,479],{"class":91},[81,1798,604],{"class":118},[81,1800,449],{"class":91},[81,1802,1803],{"class":83,"line":609},[81,1804,109],{"emptyLinePlaceholder":108},[81,1806,1807],{"class":83,"line":614},[81,1808,617],{"class":395},[81,1810,1811,1813,1815,1817],{"class":83,"line":620},[81,1812,303],{"class":87},[81,1814,625],{"class":135},[81,1816,309],{"class":87},[81,1818,565],{"class":91},[81,1820,1821,1823,1825,1827,1829],{"class":83,"line":632},[81,1822,479],{"class":91},[81,1824,573],{"class":118},[81,1826,271],{"class":91},[81,1828,578],{"class":98},[81,1830,643],{"class":91},[81,1832,1833,1835,1837],{"class":83,"line":646},[81,1834,479],{"class":91},[81,1836,593],{"class":118},[81,1838,653],{"class":91},[81,1840,1841,1843,1845,1847,1849],{"class":83,"line":656},[81,1842,479],{"class":91},[81,1844,604],{"class":118},[81,1846,271],{"class":91},[81,1848,665],{"class":98},[81,1850,342],{"class":91},[81,1852,1853],{"class":83,"line":670},[81,1854,109],{"emptyLinePlaceholder":108},[81,1856,1857],{"class":83,"line":675},[81,1858,678],{"class":395},[81,1860,1861,1863,1865,1867,1869,1871],{"class":83,"line":681},[81,1862,303],{"class":87},[81,1864,686],{"class":135},[81,1866,309],{"class":87},[81,1868,691],{"class":91},[81,1870,694],{"class":87},[81,1872,697],{"class":91},[81,1874,1875],{"class":83,"line":700},[81,1876,109],{"emptyLinePlaceholder":108},[81,1878,1879,1881,1883,1885],{"class":83,"line":705},[81,1880,354],{"class":87},[81,1882,357],{"class":91},[81,1884,360],{"class":87},[81,1886,714],{"class":91},[81,1888,1889,1891,1893,1895],{"class":83,"line":717},[81,1890,369],{"class":87},[81,1892,372],{"class":91},[81,1894,375],{"class":135},[81,1896,378],{"class":91},[81,1898,1899],{"class":83,"line":728},[81,1900,384],{"class":91},[81,1902,1903],{"class":83,"line":733},[81,1904,109],{"emptyLinePlaceholder":108},[81,1906,1907],{"class":83,"line":738},[81,1908,741],{"class":395},[81,1910,1911,1913],{"class":83,"line":744},[81,1912,747],{"class":87},[81,1914,122],{"class":91},[81,1916,1917,1919,1921,1923,1925,1927,1929,1931],{"class":83,"line":752},[81,1918,755],{"class":87},[81,1920,758],{"class":135},[81,1922,309],{"class":87},[81,1924,331],{"class":91},[81,1926,334],{"class":118},[81,1928,271],{"class":91},[81,1930,769],{"class":98},[81,1932,342],{"class":91},[81,1934,1935,1937,1939,1941,1943,1945,1947,1949,1951,1953,1955,1957,1959,1961],{"class":83,"line":774},[81,1936,755],{"class":87},[81,1938,779],{"class":135},[81,1940,132],{"class":87},[81,1942,119],{"class":118},[81,1944,309],{"class":87},[81,1946,788],{"class":91},[81,1948,791],{"class":87},[81,1950,794],{"class":135},[81,1952,62],{"class":91},[81,1954,799],{"class":118},[81,1956,802],{"class":91},[81,1958,132],{"class":87},[81,1960,807],{"class":135},[81,1962,102],{"class":91},[81,1964,1965,1967,1969,1971],{"class":83,"line":812},[81,1966,369],{"class":87},[81,1968,372],{"class":91},[81,1970,819],{"class":135},[81,1972,822],{"class":91},[81,1974,1975,1977,1979],{"class":83,"line":825},[81,1976,828],{"class":91},[81,1978,831],{"class":87},[81,1980,834],{"class":91},[81,1982,1983,1985,1987,1989],{"class":83,"line":837},[81,1984,369],{"class":87},[81,1986,372],{"class":91},[81,1988,819],{"class":135},[81,1990,378],{"class":91},[81,1992,1993],{"class":83,"line":848},[81,1994,384],{"class":91},[81,1996,1997],{"class":83,"line":853},[81,1998,207],{"class":91},[26,2000,859],{"id":858},[11,2002,862],{},[864,2004,867],{"id":866},[11,2006,870,2007,874],{},[42,2008,873],{},[34,2010,2011],{"className":75,"code":877,"language":77,"meta":40,"style":40},[42,2012,2013,2017,2023,2027,2039,2043],{"__ignoreMap":40},[81,2014,2015],{"class":83,"line":84},[81,2016,884],{"class":395},[81,2018,2019,2021],{"class":83,"line":105},[81,2020,873],{"class":118},[81,2022,891],{"class":91},[81,2024,2025],{"class":83,"line":112},[81,2026,896],{"class":91},[81,2028,2029,2031,2033,2035,2037],{"class":83,"line":125},[81,2030,901],{"class":91},[81,2032,904],{"class":98},[81,2034,907],{"class":91},[81,2036,819],{"class":135},[81,2038,912],{"class":91},[81,2040,2041],{"class":83,"line":141},[81,2042,917],{"class":91},[81,2044,2045],{"class":83,"line":154},[81,2046,922],{"class":91},[864,2048,926],{"id":925},[11,2050,929],{},[34,2052,2053],{"className":75,"code":932,"language":77,"meta":40,"style":40},[42,2054,2055,2059,2071,2075,2085,2101,2121,2125,2135,2151,2157,2165,2175,2179,2187,2197,2201,2205,2215,2227,2239,2251,2263,2267,2271,2275,2281],{"__ignoreMap":40},[81,2056,2057],{"class":83,"line":84},[81,2058,939],{"class":395},[81,2060,2061,2063,2065,2067,2069],{"class":83,"line":105},[81,2062,88],{"class":87},[81,2064,946],{"class":91},[81,2066,95],{"class":87},[81,2068,951],{"class":98},[81,2070,102],{"class":91},[81,2072,2073],{"class":83,"line":112},[81,2074,109],{"emptyLinePlaceholder":108},[81,2076,2077,2079,2081,2083],{"class":83,"line":125},[81,2078,262],{"class":87},[81,2080,265],{"class":87},[81,2082,966],{"class":118},[81,2084,969],{"class":91},[81,2086,2087,2089,2091,2093,2095,2097,2099],{"class":83,"line":141},[81,2088,303],{"class":87},[81,2090,976],{"class":135},[81,2092,309],{"class":87},[81,2094,981],{"class":118},[81,2096,271],{"class":91},[81,2098,375],{"class":135},[81,2100,342],{"class":91},[81,2102,2103,2105,2107,2109,2111,2113,2115,2117,2119],{"class":83,"line":154},[81,2104,303],{"class":87},[81,2106,779],{"class":135},[81,2108,309],{"class":87},[81,2110,981],{"class":118},[81,2112,1000],{"class":91},[81,2114,1003],{"class":135},[81,2116,1006],{"class":91},[81,2118,1009],{"class":135},[81,2120,342],{"class":91},[81,2122,2123],{"class":83,"line":167},[81,2124,109],{"emptyLinePlaceholder":108},[81,2126,2127,2129,2131,2133],{"class":83,"line":179},[81,2128,1020],{"class":118},[81,2130,1023],{"class":91},[81,2132,1026],{"class":87},[81,2134,122],{"class":91},[81,2136,2137,2139,2141,2143,2145,2147,2149],{"class":83,"line":191},[81,2138,755],{"class":87},[81,2140,1035],{"class":135},[81,2142,309],{"class":87},[81,2144,1040],{"class":91},[81,2146,1043],{"class":87},[81,2148,1046],{"class":135},[81,2150,1049],{"class":91},[81,2152,2153,2155],{"class":83,"line":204},[81,2154,1054],{"class":87},[81,2156,1057],{"class":91},[81,2158,2159,2161,2163],{"class":83,"line":210},[81,2160,1062],{"class":91},[81,2162,1065],{"class":118},[81,2164,449],{"class":91},[81,2166,2167,2169,2171,2173],{"class":83,"line":215},[81,2168,1062],{"class":91},[81,2170,1074],{"class":118},[81,2172,1077],{"class":91},[81,2174,1080],{"class":395},[81,2176,2177],{"class":83,"line":225},[81,2178,1085],{"class":91},[81,2180,2181,2183,2185],{"class":83,"line":237},[81,2182,1090],{"class":91},[81,2184,1093],{"class":87},[81,2186,1096],{"class":91},[81,2188,2189,2191,2193,2195],{"class":83,"line":249},[81,2190,1101],{"class":91},[81,2192,1093],{"class":87},[81,2194,1106],{"class":135},[81,2196,102],{"class":91},[81,2198,2199],{"class":83,"line":254},[81,2200,109],{"emptyLinePlaceholder":108},[81,2202,2203],{"class":83,"line":259},[81,2204,1117],{"class":395},[81,2206,2207,2209,2211,2213],{"class":83,"line":300},[81,2208,1122],{"class":87},[81,2210,1125],{"class":135},[81,2212,309],{"class":87},[81,2214,1130],{"class":91},[81,2216,2217,2219,2221,2223,2225],{"class":83,"line":321},[81,2218,1135],{"class":91},[81,2220,1138],{"class":118},[81,2222,271],{"class":91},[81,2224,1143],{"class":98},[81,2226,1146],{"class":91},[81,2228,2229,2231,2233,2235,2237],{"class":83,"line":345},[81,2230,1135],{"class":91},[81,2232,1138],{"class":118},[81,2234,271],{"class":91},[81,2236,1157],{"class":98},[81,2238,1160],{"class":91},[81,2240,2241,2243,2245,2247,2249],{"class":83,"line":351},[81,2242,1135],{"class":91},[81,2244,1138],{"class":118},[81,2246,271],{"class":91},[81,2248,1171],{"class":98},[81,2250,1174],{"class":91},[81,2252,2253,2255,2257,2259,2261],{"class":83,"line":366},[81,2254,1135],{"class":91},[81,2256,1138],{"class":118},[81,2258,271],{"class":91},[81,2260,1185],{"class":98},[81,2262,1188],{"class":91},[81,2264,2265],{"class":83,"line":381},[81,2266,1193],{"class":91},[81,2268,2269],{"class":83,"line":387},[81,2270,1198],{"class":91},[81,2272,2273],{"class":83,"line":392},[81,2274,109],{"emptyLinePlaceholder":108},[81,2276,2277,2279],{"class":83,"line":399},[81,2278,1207],{"class":87},[81,2280,1210],{"class":91},[81,2282,2283],{"class":83,"line":452},[81,2284,207],{"class":91},[26,2286,1218],{"id":1217},[11,2288,1221,2289,357,2291,1228],{},[15,2290,1224],{},[42,2292,1227],{},[11,2294,1231],{},[1233,2296,2297,2301,2309,2313],{},[1236,2298,2299,1241],{},[15,2300,1240],{},[1236,2302,2303,1247,2305,1251,2307,62],{},[15,2304,1246],{},[42,2306,1250],{},[42,2308,1227],{},[1236,2310,2311,1259],{},[15,2312,1258],{},[1236,2314,2315,1265,2317,1269],{},[15,2316,1264],{},[42,2318,1268],{},[11,2320,1272,2321,1278,2323,62],{},[1274,2322,1277],{"href":1276},[1274,2324,1282],{"href":1281},[11,2326,1285,2327,1290],{},[1274,2328,1289],{"href":1288},[26,2330,1294],{"id":1293},[1296,2332,2333,2338],{},[1236,2334,2335,1306],{},[1274,2336,1305],{"href":1302,"rel":2337},[1304],[1236,2339,2340,1314],{},[1274,2341,1313],{"href":1311,"rel":2342},[1304],[11,2344,1317],{},[11,2346,1320,2347,1325,2349,1330],{},[1274,2348,1324],{"href":1323},[1274,2350,1329],{"href":1328},[1332,2352,1334],{},{"title":40,"searchDepth":105,"depth":105,"links":2354},[2355,2356,2357,2361,2362],{"id":28,"depth":105,"text":29},{"id":46,"depth":105,"text":1339},{"id":858,"depth":105,"text":859,"children":2358},[2359,2360],{"id":866,"depth":112,"text":867},{"id":925,"depth":112,"text":926},{"id":1217,"depth":105,"text":1218},{"id":1293,"depth":105,"text":1294},[2364,2365,2366],{"q":1351,"a":1352},{"q":1354,"a":1355},{"q":1357,"a":1358},{},{"title":6,"description":1347},[1366,1367,1368,1369,1370],{"id":2371,"title":2372,"body":2373,"date":2938,"description":2939,"extension":1348,"faq":2940,"image":2950,"lang":1360,"meta":2951,"navigation":108,"path":2952,"published":108,"readTime":215,"seo":2953,"stem":2954,"tags":2955,"updated":1346,"__hash__":2960},"blog\u002Fblog\u002Fen\u002Fgenerative-engine-optimization-geo.md","Generative Engine Optimization (GEO): The New SEO for AI Search",{"type":8,"value":2374,"toc":2927},[2375,2378,2381,2387,2390,2394,2397,2403,2406,2410,2418,2421,2520,2527,2531,2535,2538,2552,2556,2559,2573,2577,2580,2584,2587,2862,2874,2881,2883,2909,2912,2924],[11,2376,2377],{},"The web search ecosystem is undergoing its most revolutionary change since Google’s inception in 1998. Traditional search engine optimization (SEO) is built on ranking links on a Search Engine Results Page (SERP). Today, however, millions of users are bypassing search result pages altogether. They query conversational AI assistants—such as ChatGPT Search, Perplexity, Gemini, and Claude—to get direct, synthesized answers.",[11,2379,2380],{},"If your product, SaaS, or agency is not recommended and cited in these AI responses, your business is virtually invisible to a rapidly growing demographic of buyers.",[11,2382,2383,2384,62],{},"To stay competitive, you must optimize your site for large language model (LLM) search engines. This is the discipline of ",[15,2385,2386],{},"Generative Engine Optimization (GEO)",[11,2388,2389],{},"In this deep dive, we will explore how conversational search engines work, analyze academic ranking factors, and implement technical adjustments to secure your brand's placement in AI summaries.",[26,2391,2393],{"id":2392},"the-ai-search-rag-retrieval-pipeline","The AI Search (RAG) Retrieval Pipeline",[11,2395,2396],{},"Unlike classic crawlers that map keywords to index scores, AI search engines use a hybrid retrieval pipeline:",[34,2398,2401],{"className":2399,"code":2400,"language":39,"meta":40},[37],"[User asks: \"Best multi-tenant CRM for real estate\"]\n                             │\n                             v\n           (AI Query Understanding & Search)\n    LLM rewrites query -> Runs web search (Bing\u002FGoogle API)\n                             │\n                             v\n           (Scraping & Embedding top 10 results)\n     AI scrapers read HTML -> Vectorize content chunks\n                             │\n                             v\n            (RAG Re-ranking & Synthesis)\n     Matches question vector against scraped chunks ->\n  Selects top 3 sources -> Synthesizes single answer with links\n                             │\n                             v\n[Output: \"I recommend AcmeCRM [1] because of its postgres schema isolation [2]\"]\n",[42,2402,2400],{"__ignoreMap":40},[11,2404,2405],{},"To rank in this ecosystem, your content must satisfy two criteria: it must appear in the top search indexes, and it must be structured in a way that the LLM re-ranker selects it as the most authoritative, easy-to-summarize source.",[26,2407,2409],{"id":2408},"_1-key-geo-optimization-techniques-backed-by-research","1. Key GEO Optimization Techniques (Backed by Research)",[11,2411,2412,2413,2417],{},"In 2023, researchers from Princeton, Georgia Tech, and the Allen Institute for AI published a landmark study titled ",[2414,2415,2416],"em",{},"“Generative Engine Optimization”",". They systematically tested various content modification techniques to see what factors influence an LLM’s probability of citing a website.",[11,2419,2420],{},"Here is the data-backed rubric of what works best:",[2422,2423,2424,2441],"table",{},[2425,2426,2427],"thead",{},[2428,2429,2430,2435,2438],"tr",{},[2431,2432,2434],"th",{"align":2433},"left","GEO Strategy",[2431,2436,2437],{"align":2433},"What it means",[2431,2439,2440],{"align":2433},"Citation Increase",[2442,2443,2444,2460,2475,2490,2505],"tbody",{},[2428,2445,2446,2452,2455],{},[2447,2448,2449],"td",{"align":2433},[15,2450,2451],{},"Cite Sources",[2447,2453,2454],{"align":2433},"Adding outbound links to high-authority academic papers, government stats, or news portals.",[2447,2456,2457],{"align":2433},[15,2458,2459],{},"+40.3%",[2428,2461,2462,2467,2470],{},[2447,2463,2464],{"align":2433},[15,2465,2466],{},"Include Statistics",[2447,2468,2469],{"align":2433},"Replacing vague claims with verified quantitative data and numerical metrics.",[2447,2471,2472],{"align":2433},[15,2473,2474],{},"+37.5%",[2428,2476,2477,2482,2485],{},[2447,2478,2479],{"align":2433},[15,2480,2481],{},"Information Gain",[2447,2483,2484],{"align":2433},"Adding unique data or insights not present in other top search results.",[2447,2486,2487],{"align":2433},[15,2488,2489],{},"+32.1%",[2428,2491,2492,2497,2500],{},[2447,2493,2494],{"align":2433},[15,2495,2496],{},"Q&A Formatting",[2447,2498,2499],{"align":2433},"Structuring headlines as questions followed by a direct, concise answer.",[2447,2501,2502],{"align":2433},[15,2503,2504],{},"+25.8%",[2428,2506,2507,2512,2515],{},[2447,2508,2509],{"align":2433},[15,2510,2511],{},"Markdown Tables",[2447,2513,2514],{"align":2433},"Organizing complex comparisons in clean markdown tables.",[2447,2516,2517],{"align":2433},[15,2518,2519],{},"+18.2%",[11,2521,2522,2523,2526],{},"Traditional SEO tactics like keyword stuffing actually ",[2414,2524,2525],{},"decrease"," citation scores in LLM re-rankers because they reduce readability and lower the model's summarization score.",[26,2528,2530],{"id":2529},"_2-practical-technical-geo-checklist","2. Practical Technical GEO Checklist",[864,2532,2534],{"id":2533},"a-implement-qa-style-h2h3-headlines","A. Implement Q&A-Style H2\u002FH3 Headlines",[11,2536,2537],{},"AI models are trained to map answers to user queries. If a user asks a question, the LLM will scan search results for exact matching headers.",[1296,2539,2540,2546],{},[1236,2541,2542,2545],{},[2414,2543,2544],{},"Incorrect",": \"Our Billing Infrastructure Customizations\"",[1236,2547,2548,2551],{},[2414,2549,2550],{},"Correct",": \"How does the platform handle subscription billing?\" Follow this header immediately with a clear, concise 2-sentence summary of your architecture, followed by deeper details.",[864,2553,2555],{"id":2554},"b-add-outbound-citation-anchors","B. Add Outbound Citation Anchors",[11,2557,2558],{},"When referencing industry standards, link to authoritative repositories.",[1296,2560,2561],{},[1236,2562,2563,2566,2567,2572],{},[2414,2564,2565],{},"Example",": \"According to the ",[1274,2568,2571],{"href":2569,"rel":2570},"https:\u002F\u002Fowasp.org\u002Fwww-project-top-ten\u002F",[1304],"OWASP Top 10 Security Standards",", input sanitization is critical to prevent injection attacks...\"\nThis outbound link signals high authority to the LLM re-ranker.",[864,2574,2576],{"id":2575},"c-optimize-for-readability-flesch-kincaid-score","C. Optimize for Readability (Flesch-Kincaid Score)",[11,2578,2579],{},"LLMs prefer texts that are easy to parse and compile. Use clear, active voice, short sentences, and structured bullet points. Avoid flowery promotional adjectives.",[26,2581,2583],{"id":2582},"_3-feeding-clean-data-to-ai-bots-using-json-ld","3. Feeding Clean Data to AI bots using JSON-LD",[11,2585,2586],{},"To remove all ambiguity about your services, pricing, and entity relations, output clean semantic structures. Here is a JSON-LD snippet for a consultancy service that should be dynamically injected into your site's header:",[34,2588,2592],{"className":2589,"code":2590,"language":2591,"meta":40,"style":40},"language-json shiki shiki-themes github-dark","{\n  \"@context\": \"https:\u002F\u002Fschema.org\",\n  \"@type\": \"ConsultingService\",\n  \"@id\": \"https:\u002F\u002Falexrel.com\u002Fconsultations#service\",\n  \"name\": \"Technical Architecture Consultation\",\n  \"description\": \"Professional codebase audits, database scaling reviews, and SaaS MVP planning.\",\n  \"provider\": {\n    \"@type\": \"Person\",\n    \"name\": \"Alex Rel\",\n    \"jobTitle\": \"Senior Fullstack Developer\",\n    \"url\": \"https:\u002F\u002Falexrel.com\"\n  },\n  \"offers\": {\n    \"@type\": \"Offer\",\n    \"priceCurrency\": \"USD\",\n    \"price\": \"250\",\n    \"priceSpecification\": {\n      \"@type\": \"UnitPriceSpecification\",\n      \"priceType\": \"https:\u002F\u002Fschema.org\u002FRecurringPrice\",\n      \"billingIncrement\": 1,\n      \"referenceQuantity\": {\n        \"@type\": \"QuantitativeValue\",\n        \"value\": 1,\n        \"unitCode\": \"HUR\"\n      }\n    }\n  }\n}\n","json",[42,2593,2594,2599,2613,2625,2637,2649,2661,2669,2681,2693,2705,2715,2720,2727,2738,2750,2762,2769,2781,2793,2805,2812,2824,2835,2845,2850,2854,2858],{"__ignoreMap":40},[81,2595,2596],{"class":83,"line":84},[81,2597,2598],{"class":91},"{\n",[81,2600,2601,2604,2607,2610],{"class":83,"line":105},[81,2602,2603],{"class":135},"  \"@context\"",[81,2605,2606],{"class":91},": ",[81,2608,2609],{"class":98},"\"https:\u002F\u002Fschema.org\"",[81,2611,2612],{"class":91},",\n",[81,2614,2615,2618,2620,2623],{"class":83,"line":112},[81,2616,2617],{"class":135},"  \"@type\"",[81,2619,2606],{"class":91},[81,2621,2622],{"class":98},"\"ConsultingService\"",[81,2624,2612],{"class":91},[81,2626,2627,2630,2632,2635],{"class":83,"line":125},[81,2628,2629],{"class":135},"  \"@id\"",[81,2631,2606],{"class":91},[81,2633,2634],{"class":98},"\"https:\u002F\u002Falexrel.com\u002Fconsultations#service\"",[81,2636,2612],{"class":91},[81,2638,2639,2642,2644,2647],{"class":83,"line":141},[81,2640,2641],{"class":135},"  \"name\"",[81,2643,2606],{"class":91},[81,2645,2646],{"class":98},"\"Technical Architecture Consultation\"",[81,2648,2612],{"class":91},[81,2650,2651,2654,2656,2659],{"class":83,"line":154},[81,2652,2653],{"class":135},"  \"description\"",[81,2655,2606],{"class":91},[81,2657,2658],{"class":98},"\"Professional codebase audits, database scaling reviews, and SaaS MVP planning.\"",[81,2660,2612],{"class":91},[81,2662,2663,2666],{"class":83,"line":167},[81,2664,2665],{"class":135},"  \"provider\"",[81,2667,2668],{"class":91},": {\n",[81,2670,2671,2674,2676,2679],{"class":83,"line":179},[81,2672,2673],{"class":135},"    \"@type\"",[81,2675,2606],{"class":91},[81,2677,2678],{"class":98},"\"Person\"",[81,2680,2612],{"class":91},[81,2682,2683,2686,2688,2691],{"class":83,"line":191},[81,2684,2685],{"class":135},"    \"name\"",[81,2687,2606],{"class":91},[81,2689,2690],{"class":98},"\"Alex Rel\"",[81,2692,2612],{"class":91},[81,2694,2695,2698,2700,2703],{"class":83,"line":204},[81,2696,2697],{"class":135},"    \"jobTitle\"",[81,2699,2606],{"class":91},[81,2701,2702],{"class":98},"\"Senior Fullstack Developer\"",[81,2704,2612],{"class":91},[81,2706,2707,2710,2712],{"class":83,"line":210},[81,2708,2709],{"class":135},"    \"url\"",[81,2711,2606],{"class":91},[81,2713,2714],{"class":98},"\"https:\u002F\u002Falexrel.com\"\n",[81,2716,2717],{"class":83,"line":215},[81,2718,2719],{"class":91},"  },\n",[81,2721,2722,2725],{"class":83,"line":225},[81,2723,2724],{"class":135},"  \"offers\"",[81,2726,2668],{"class":91},[81,2728,2729,2731,2733,2736],{"class":83,"line":237},[81,2730,2673],{"class":135},[81,2732,2606],{"class":91},[81,2734,2735],{"class":98},"\"Offer\"",[81,2737,2612],{"class":91},[81,2739,2740,2743,2745,2748],{"class":83,"line":249},[81,2741,2742],{"class":135},"    \"priceCurrency\"",[81,2744,2606],{"class":91},[81,2746,2747],{"class":98},"\"USD\"",[81,2749,2612],{"class":91},[81,2751,2752,2755,2757,2760],{"class":83,"line":254},[81,2753,2754],{"class":135},"    \"price\"",[81,2756,2606],{"class":91},[81,2758,2759],{"class":98},"\"250\"",[81,2761,2612],{"class":91},[81,2763,2764,2767],{"class":83,"line":259},[81,2765,2766],{"class":135},"    \"priceSpecification\"",[81,2768,2668],{"class":91},[81,2770,2771,2774,2776,2779],{"class":83,"line":300},[81,2772,2773],{"class":135},"      \"@type\"",[81,2775,2606],{"class":91},[81,2777,2778],{"class":98},"\"UnitPriceSpecification\"",[81,2780,2612],{"class":91},[81,2782,2783,2786,2788,2791],{"class":83,"line":321},[81,2784,2785],{"class":135},"      \"priceType\"",[81,2787,2606],{"class":91},[81,2789,2790],{"class":98},"\"https:\u002F\u002Fschema.org\u002FRecurringPrice\"",[81,2792,2612],{"class":91},[81,2794,2795,2798,2800,2803],{"class":83,"line":345},[81,2796,2797],{"class":135},"      \"billingIncrement\"",[81,2799,2606],{"class":91},[81,2801,2802],{"class":135},"1",[81,2804,2612],{"class":91},[81,2806,2807,2810],{"class":83,"line":351},[81,2808,2809],{"class":135},"      \"referenceQuantity\"",[81,2811,2668],{"class":91},[81,2813,2814,2817,2819,2822],{"class":83,"line":366},[81,2815,2816],{"class":135},"        \"@type\"",[81,2818,2606],{"class":91},[81,2820,2821],{"class":98},"\"QuantitativeValue\"",[81,2823,2612],{"class":91},[81,2825,2826,2829,2831,2833],{"class":83,"line":381},[81,2827,2828],{"class":135},"        \"value\"",[81,2830,2606],{"class":91},[81,2832,2802],{"class":135},[81,2834,2612],{"class":91},[81,2836,2837,2840,2842],{"class":83,"line":387},[81,2838,2839],{"class":135},"        \"unitCode\"",[81,2841,2606],{"class":91},[81,2843,2844],{"class":98},"\"HUR\"\n",[81,2846,2847],{"class":83,"line":392},[81,2848,2849],{"class":91},"      }\n",[81,2851,2852],{"class":83,"line":399},[81,2853,1193],{"class":91},[81,2855,2856],{"class":83,"line":452},[81,2857,384],{"class":91},[81,2859,2860],{"class":83,"line":457},[81,2861,207],{"class":91},[11,2863,2864,2865,2868,2869,2873],{},"When optimizing my own projects, such as the ",[1274,2866,2867],{"href":1276},"TeleGo.io platform"," and the ",[1274,2870,2872],{"href":2871},"\u002Fprojects\u002Flingvohabit","LingvoHabit language portal",", I implemented these precise structural adjustments. Today, both services are successfully cataloged and actively cited in real-time ChatGPT Search and Perplexity queries.",[11,2875,2876,2877,62],{},"To learn how to configure your robots.txt and schema structures to facilitate this, read my technical guide on ",[1274,2878,2880],{"href":2879},"\u002Fblog\u002Fwebsite-optimization-ai-crawlers","Optimizing your Website for AI Crawlers: Robots.txt & JSON-LD",[26,2882,1294],{"id":1293},[1296,2884,2885,2893,2901],{},[1236,2886,2887,2892],{},[1274,2888,2891],{"href":2889,"rel":2890},"https:\u002F\u002Farxiv.org\u002Fabs\u002F2311.09735",[1304],"GEO: Generative Engine Optimization"," — the original Princeton \u002F Georgia Tech \u002F Allen AI study behind the strategy table above",[1236,2894,2895,2900],{},[1274,2896,2899],{"href":2897,"rel":2898},"https:\u002F\u002Fdevelopers.google.com\u002Fsearch\u002Fdocs",[1304],"Google Search Central"," — official documentation on indexing and structured data",[1236,2902,2903,2908],{},[1274,2904,2907],{"href":2905,"rel":2906},"https:\u002F\u002Fplatform.openai.com\u002Fdocs\u002Fbots",[1304],"OpenAI crawler documentation"," — how GPTBot and OAI-SearchBot scan websites",[11,2910,2911],{},"By structuring your content for the Generative Web today, you secure your brand's presence in the AI search portals of tomorrow.",[11,2913,2914,2915,2919,2920,2923],{},"If you want to implement GEO strategies, perform an AI search audit of your website, or optimize your content for ChatGPT and Perplexity, explore my ",[1274,2916,2918],{"href":2917},"\u002Fgeo-seo","GEO & SEO Optimization Services"," or book a ",[1274,2921,2922],{"href":1328},"Technical Consultation"," to get started.",[1332,2925,2926],{},"html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}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":2928},[2929,2930,2931,2936,2937],{"id":2392,"depth":105,"text":2393},{"id":2408,"depth":105,"text":2409},{"id":2529,"depth":105,"text":2530,"children":2932},[2933,2934,2935],{"id":2533,"depth":112,"text":2534},{"id":2554,"depth":112,"text":2555},{"id":2575,"depth":112,"text":2576},{"id":2582,"depth":105,"text":2583},{"id":1293,"depth":105,"text":1294},"2026-07-02","A technical guide on Generative Engine Optimization (GEO) strategies to improve brand citation and recommendation frequencies in ChatGPT, Perplexity, and Gemini.",[2941,2944,2947],{"q":2942,"a":2943},"What is GEO in simple terms?","GEO (Generative Engine Optimization) is optimizing a website so AI engines — ChatGPT, Perplexity, Gemini — can find, cite, and recommend your brand inside their answers. Where SEO fights for a ranking position, GEO fights for a mention inside the generated answer itself.",{"q":2945,"a":2946},"Does GEO replace classic SEO?","No, they complement each other. AI engines ride on classic search indexes (ChatGPT Search uses Bing, Gemini uses Google), so GEO doesn't work without basic indexability. SEO brings traffic from result pages; GEO brings mentions and clicks from AI answers.",{"q":2948,"a":2949},"Which GEO techniques deliver the biggest lift?","Per the Princeton \u002F Georgia Tech \u002F Allen AI study: citing authoritative sources (+40%), concrete statistics in the text (+37%), unique facts competitors don't have (+32%), and question-and-answer headings (+26%).","\u002Fimages\u002Fblog\u002Fblog_geo_seo.jpg",{},"\u002Fblog\u002Fen\u002Fgenerative-engine-optimization-geo",{"title":2372,"description":2939},"blog\u002Fen\u002Fgenerative-engine-optimization-geo",[2956,2957,2958,2959],"GEO","SEO","AI","Marketing","TKFkrhvWGfdJMdezJ6T4Sl12RWptm2ar9L2mwKcgEfM",{"id":2962,"title":2963,"body":2964,"date":3568,"description":3569,"extension":1348,"faq":3570,"image":3580,"lang":1360,"meta":3581,"navigation":108,"path":3582,"published":108,"readTime":215,"seo":3583,"stem":3584,"tags":3585,"updated":1346,"__hash__":3588},"blog\u002Fblog\u002Fen\u002Fwebsite-optimization-ai-crawlers.md","Optimizing Your Website for AI Crawlers: Robots.txt & JSON-LD",{"type":8,"value":2965,"toc":3560},[2966,2985,2988,2999,3003,3006,3012,3018,3022,3025,3032,3042,3357,3361,3364,3371,3375,3403,3496,3499,3508,3510,3542,3548,3557],[11,2967,2968,2969,2972,2973,2976,2977,2980,2981,2984],{},"The web scraping landscape has shifted. The scrapers visiting your server are no longer limited to Googlebot, YandexBot, or Bingbot. Today, your server logs are likely dominated by ",[15,2970,2971],{},"GPTBot"," (OpenAI), ",[15,2974,2975],{},"ClaudeBot"," (Anthropic), ",[15,2978,2979],{},"PerplexityBot",", and ",[15,2982,2983],{},"OAI-SearchBot"," (OpenAI’s conversational search engine agent).",[11,2986,2987],{},"As these LLM agents actively search the web to answer live user queries, you must ensure that your technical setup permits them to scrape public information, maps your services accurately, and prevents them from reading private database endpoints.",[11,2989,2990,2991,2994,2995,2998],{},"In this technical guide, we will configure an AI-friendly ",[42,2992,2993],{},"robots.txt"," policy, implement nested ",[42,2996,2997],{},"JSON-LD"," schemas, and discuss how to bypass hydration\u002FJavaScript crawling issues.",[26,3000,3002],{"id":3001},"_1-directives-configuring-robotstxt-for-ai-crawlers","1. Directives: Configuring Robots.txt for AI Crawlers",[11,3004,3005],{},"You should treat AI bots as distinct entities in your crawl configuration. While you want them to index your blog articles and service offerings so they can cite you, you must block them from indexing resources like API endpoints, user checkout routes, or local search configurations.",[11,3007,3008,3009,3011],{},"Here is a secure, production-ready ",[42,3010,2993],{}," configuration:",[34,3013,3016],{"className":3014,"code":3015,"language":39,"meta":40},[37],"# Global rules for all standard crawlers\nUser-agent: *\nAllow: \u002F\nDisallow: \u002Fadmin\u002F\nDisallow: \u002Fapi\u002F\nDisallow: \u002Fcheckout\u002F\n\n# OpenAI ChatGPT User Agent (Used to retrieve links for ChatGPT answers)\nUser-agent: GPTBot\nAllow: \u002F\nDisallow: \u002Fadmin\u002F\nDisallow: \u002Fapi\u002F\nDisallow: \u002Fcheckout\u002F\nDisallow: \u002Fprivate\u002F\n\n# OpenAI Search Agent (Used for real-time search engine features)\nUser-agent: OAI-SearchBot\nAllow: \u002F\nDisallow: \u002Fadmin\u002F\nDisallow: \u002Fapi\u002F\n\n# Anthropic Claude User Agent\nUser-agent: ClaudeBot\nAllow: \u002F\nDisallow: \u002Fadmin\u002F\nDisallow: \u002Fapi\u002F\n\n# Perplexity Search Agent\nUser-agent: PerplexityBot\nAllow: \u002F\nDisallow: \u002Fadmin\u002F\nDisallow: \u002Fapi\u002F\n\n# Block aggressive, non-citing scrapers (Scrapers that steal data without giving traffic)\nUser-agent: CCBot\nDisallow: \u002F\nUser-agent: Amazonbot\nDisallow: \u002F\n",[42,3017,3015],{"__ignoreMap":40},[26,3019,3021],{"id":3020},"_2-implementing-nested-json-ld-schema-graphs","2. Implementing Nested JSON-LD Schema Graphs",[11,3023,3024],{},"LLMs do not parse text like human readers; they map concepts and values to semantic entities. If you describe your business in a plain paragraph, an LLM might misinterpret your prices, locations, or provider identities.",[11,3026,3027,3028,3031],{},"By injecting structured ",[15,3029,3030],{},"JSON-LD (JSON Linked Data)"," inside your page headers, you tell the crawlers exactly what your business represents.",[11,3033,3034,3035,3038,3039,132],{},"Here is an advanced nested graph representing an ",[15,3036,3037],{},"Organization"," offering a specialized ",[15,3040,3041],{},"SaaS Development Service",[34,3043,3045],{"className":2589,"code":3044,"language":2591,"meta":40,"style":40},"{\n  \"@context\": \"https:\u002F\u002Fschema.org\",\n  \"@graph\": [\n    {\n      \"@type\": \"Organization\",\n      \"@id\": \"https:\u002F\u002Falexrel.com\u002F#organization\",\n      \"name\": \"Alex Rel Development\",\n      \"url\": \"https:\u002F\u002Falexrel.com\",\n      \"logo\": \"https:\u002F\u002Falexrel.com\u002Flogo.png\",\n      \"sameAs\": [\n        \"https:\u002F\u002Fgithub.com\u002Falexrel\",\n        \"https:\u002F\u002Flinkedin.com\u002Fin\u002Falexrel\"\n      ]\n    },\n    {\n      \"@type\": \"Service\",\n      \"@id\": \"https:\u002F\u002Falexrel.com\u002Fsaas-development#service\",\n      \"name\": \"B2B SaaS Architecture and Development\",\n      \"description\": \"Full-cycle SaaS development from database schema isolation to Stripe webhook billing and vector support bots.\",\n      \"provider\": {\n        \"@id\": \"https:\u002F\u002Falexrel.com\u002F#organization\"\n      },\n      \"areaServed\": \"Worldwide\",\n      \"offers\": {\n        \"@type\": \"Offer\",\n        \"priceCurrency\": \"USD\",\n        \"price\": \"5000\",\n        \"priceSpecification\": {\n          \"@type\": \"UnitPriceSpecification\",\n          \"priceType\": \"https:\u002F\u002Fschema.org\u002FMinimumPrice\",\n          \"price\": \"5000\",\n          \"priceCurrency\": \"USD\"\n        }\n      }\n    }\n  ]\n}\n",[42,3046,3047,3051,3061,3069,3074,3085,3097,3109,3121,3133,3140,3147,3152,3157,3162,3166,3177,3188,3199,3211,3218,3228,3233,3245,3252,3262,3273,3285,3292,3303,3315,3326,3336,3341,3345,3349,3353],{"__ignoreMap":40},[81,3048,3049],{"class":83,"line":84},[81,3050,2598],{"class":91},[81,3052,3053,3055,3057,3059],{"class":83,"line":105},[81,3054,2603],{"class":135},[81,3056,2606],{"class":91},[81,3058,2609],{"class":98},[81,3060,2612],{"class":91},[81,3062,3063,3066],{"class":83,"line":112},[81,3064,3065],{"class":135},"  \"@graph\"",[81,3067,3068],{"class":91},": [\n",[81,3070,3071],{"class":83,"line":125},[81,3072,3073],{"class":91},"    {\n",[81,3075,3076,3078,3080,3083],{"class":83,"line":141},[81,3077,2773],{"class":135},[81,3079,2606],{"class":91},[81,3081,3082],{"class":98},"\"Organization\"",[81,3084,2612],{"class":91},[81,3086,3087,3090,3092,3095],{"class":83,"line":154},[81,3088,3089],{"class":135},"      \"@id\"",[81,3091,2606],{"class":91},[81,3093,3094],{"class":98},"\"https:\u002F\u002Falexrel.com\u002F#organization\"",[81,3096,2612],{"class":91},[81,3098,3099,3102,3104,3107],{"class":83,"line":167},[81,3100,3101],{"class":135},"      \"name\"",[81,3103,2606],{"class":91},[81,3105,3106],{"class":98},"\"Alex Rel Development\"",[81,3108,2612],{"class":91},[81,3110,3111,3114,3116,3119],{"class":83,"line":179},[81,3112,3113],{"class":135},"      \"url\"",[81,3115,2606],{"class":91},[81,3117,3118],{"class":98},"\"https:\u002F\u002Falexrel.com\"",[81,3120,2612],{"class":91},[81,3122,3123,3126,3128,3131],{"class":83,"line":191},[81,3124,3125],{"class":135},"      \"logo\"",[81,3127,2606],{"class":91},[81,3129,3130],{"class":98},"\"https:\u002F\u002Falexrel.com\u002Flogo.png\"",[81,3132,2612],{"class":91},[81,3134,3135,3138],{"class":83,"line":204},[81,3136,3137],{"class":135},"      \"sameAs\"",[81,3139,3068],{"class":91},[81,3141,3142,3145],{"class":83,"line":210},[81,3143,3144],{"class":98},"        \"https:\u002F\u002Fgithub.com\u002Falexrel\"",[81,3146,2612],{"class":91},[81,3148,3149],{"class":83,"line":215},[81,3150,3151],{"class":98},"        \"https:\u002F\u002Flinkedin.com\u002Fin\u002Falexrel\"\n",[81,3153,3154],{"class":83,"line":225},[81,3155,3156],{"class":91},"      ]\n",[81,3158,3159],{"class":83,"line":237},[81,3160,3161],{"class":91},"    },\n",[81,3163,3164],{"class":83,"line":249},[81,3165,3073],{"class":91},[81,3167,3168,3170,3172,3175],{"class":83,"line":254},[81,3169,2773],{"class":135},[81,3171,2606],{"class":91},[81,3173,3174],{"class":98},"\"Service\"",[81,3176,2612],{"class":91},[81,3178,3179,3181,3183,3186],{"class":83,"line":259},[81,3180,3089],{"class":135},[81,3182,2606],{"class":91},[81,3184,3185],{"class":98},"\"https:\u002F\u002Falexrel.com\u002Fsaas-development#service\"",[81,3187,2612],{"class":91},[81,3189,3190,3192,3194,3197],{"class":83,"line":300},[81,3191,3101],{"class":135},[81,3193,2606],{"class":91},[81,3195,3196],{"class":98},"\"B2B SaaS Architecture and Development\"",[81,3198,2612],{"class":91},[81,3200,3201,3204,3206,3209],{"class":83,"line":321},[81,3202,3203],{"class":135},"      \"description\"",[81,3205,2606],{"class":91},[81,3207,3208],{"class":98},"\"Full-cycle SaaS development from database schema isolation to Stripe webhook billing and vector support bots.\"",[81,3210,2612],{"class":91},[81,3212,3213,3216],{"class":83,"line":345},[81,3214,3215],{"class":135},"      \"provider\"",[81,3217,2668],{"class":91},[81,3219,3220,3223,3225],{"class":83,"line":351},[81,3221,3222],{"class":135},"        \"@id\"",[81,3224,2606],{"class":91},[81,3226,3227],{"class":98},"\"https:\u002F\u002Falexrel.com\u002F#organization\"\n",[81,3229,3230],{"class":83,"line":366},[81,3231,3232],{"class":91},"      },\n",[81,3234,3235,3238,3240,3243],{"class":83,"line":381},[81,3236,3237],{"class":135},"      \"areaServed\"",[81,3239,2606],{"class":91},[81,3241,3242],{"class":98},"\"Worldwide\"",[81,3244,2612],{"class":91},[81,3246,3247,3250],{"class":83,"line":387},[81,3248,3249],{"class":135},"      \"offers\"",[81,3251,2668],{"class":91},[81,3253,3254,3256,3258,3260],{"class":83,"line":392},[81,3255,2816],{"class":135},[81,3257,2606],{"class":91},[81,3259,2735],{"class":98},[81,3261,2612],{"class":91},[81,3263,3264,3267,3269,3271],{"class":83,"line":399},[81,3265,3266],{"class":135},"        \"priceCurrency\"",[81,3268,2606],{"class":91},[81,3270,2747],{"class":98},[81,3272,2612],{"class":91},[81,3274,3275,3278,3280,3283],{"class":83,"line":452},[81,3276,3277],{"class":135},"        \"price\"",[81,3279,2606],{"class":91},[81,3281,3282],{"class":98},"\"5000\"",[81,3284,2612],{"class":91},[81,3286,3287,3290],{"class":83,"line":457},[81,3288,3289],{"class":135},"        \"priceSpecification\"",[81,3291,2668],{"class":91},[81,3293,3294,3297,3299,3301],{"class":83,"line":463},[81,3295,3296],{"class":135},"          \"@type\"",[81,3298,2606],{"class":91},[81,3300,2778],{"class":98},[81,3302,2612],{"class":91},[81,3304,3305,3308,3310,3313],{"class":83,"line":476},[81,3306,3307],{"class":135},"          \"priceType\"",[81,3309,2606],{"class":91},[81,3311,3312],{"class":98},"\"https:\u002F\u002Fschema.org\u002FMinimumPrice\"",[81,3314,2612],{"class":91},[81,3316,3317,3320,3322,3324],{"class":83,"line":518},[81,3318,3319],{"class":135},"          \"price\"",[81,3321,2606],{"class":91},[81,3323,3282],{"class":98},[81,3325,2612],{"class":91},[81,3327,3328,3331,3333],{"class":83,"line":538},[81,3329,3330],{"class":135},"          \"priceCurrency\"",[81,3332,2606],{"class":91},[81,3334,3335],{"class":98},"\"USD\"\n",[81,3337,3338],{"class":83,"line":543},[81,3339,3340],{"class":91},"        }\n",[81,3342,3343],{"class":83,"line":549},[81,3344,2849],{"class":91},[81,3346,3347],{"class":83,"line":555},[81,3348,1193],{"class":91},[81,3350,3351],{"class":83,"line":568},[81,3352,917],{"class":91},[81,3354,3355],{"class":83,"line":588},[81,3356,207],{"class":91},[26,3358,3360],{"id":3359},"_3-resolving-client-side-rendering-csr-issues-for-ai-bots","3. Resolving Client-Side Rendering (CSR) Issues for AI Bots",[11,3362,3363],{},"Unlike Googlebot, which allocates massive CPU resources to render JavaScript and execute client-side hydration, many AI search scraper agents are lightweight tools that pull raw HTML responses to minimize processing time.",[11,3365,3366,3367,3370],{},"If your web application relies entirely on client-side rendering (e.g., a standard single-page React or Vue app with an empty ",[42,3368,3369],{},"\u003Cdiv id=\"app\">\u003C\u002Fdiv>","), AI crawlers might only see a blank page, excluding your brand from their index.",[864,3372,3374],{"id":3373},"how-to-solve-this-in-nuxt-3","How to solve this in Nuxt 3:",[1233,3376,3377,3390],{},[1236,3378,3379,3382,3383,3386,3387,62],{},[15,3380,3381],{},"Enable Server-Side Rendering (SSR)",": Ensure ",[42,3384,3385],{},"ssr: true"," is configured in your ",[42,3388,3389],{},"nuxt.config.ts",[1236,3391,3392,3395,3396,3398,3399,3402],{},[15,3393,3394],{},"Use Pre-rendering for Static Pages",": Configure routes like ",[42,3397,1323],{}," and ",[42,3400,3401],{},"\u002Fsaas-development"," to build as static HTML files during deployment:",[34,3404,3406],{"className":75,"code":3405,"language":77,"meta":40,"style":40},"\u002F\u002F nuxt.config.ts\nexport default defineNuxtConfig({\n  routeRules: {\n    '\u002F': { prerender: true },\n    '\u002Fsaas-development': { prerender: true },\n    '\u002Ftelegram-bots': { prerender: true },\n    '\u002Fgeo-seo': { prerender: true },\n    '\u002Fconsultations': { prerender: true }\n  }\n});\n",[42,3407,3408,3413,3425,3430,3443,3454,3465,3476,3487,3491],{"__ignoreMap":40},[81,3409,3410],{"class":83,"line":84},[81,3411,3412],{"class":395},"\u002F\u002F nuxt.config.ts\n",[81,3414,3415,3417,3420,3423],{"class":83,"line":105},[81,3416,262],{"class":87},[81,3418,3419],{"class":87}," default",[81,3421,3422],{"class":118}," defineNuxtConfig",[81,3424,891],{"class":91},[81,3426,3427],{"class":83,"line":112},[81,3428,3429],{"class":91},"  routeRules: {\n",[81,3431,3432,3435,3438,3440],{"class":83,"line":125},[81,3433,3434],{"class":98},"    '\u002F'",[81,3436,3437],{"class":91},": { prerender: ",[81,3439,819],{"class":135},[81,3441,3442],{"class":91}," },\n",[81,3444,3445,3448,3450,3452],{"class":83,"line":141},[81,3446,3447],{"class":98},"    '\u002Fsaas-development'",[81,3449,3437],{"class":91},[81,3451,819],{"class":135},[81,3453,3442],{"class":91},[81,3455,3456,3459,3461,3463],{"class":83,"line":154},[81,3457,3458],{"class":98},"    '\u002Ftelegram-bots'",[81,3460,3437],{"class":91},[81,3462,819],{"class":135},[81,3464,3442],{"class":91},[81,3466,3467,3470,3472,3474],{"class":83,"line":167},[81,3468,3469],{"class":98},"    '\u002Fgeo-seo'",[81,3471,3437],{"class":91},[81,3473,819],{"class":135},[81,3475,3442],{"class":91},[81,3477,3478,3481,3483,3485],{"class":83,"line":179},[81,3479,3480],{"class":98},"    '\u002Fconsultations'",[81,3482,3437],{"class":91},[81,3484,819],{"class":135},[81,3486,912],{"class":91},[81,3488,3489],{"class":83,"line":191},[81,3490,384],{"class":91},[81,3492,3493],{"class":83,"line":204},[81,3494,3495],{"class":91},"});\n",[11,3497,3498],{},"Enforcing static rendering ensures that whenever GPTBot or ClaudeBot fetches your pages, they receive full, semantic text structures immediately.",[11,3500,3501,3502,3398,3505,3507],{},"When establishing the indexing architecture for ",[1274,3503,3504],{"href":2871},"LingvoHabit",[1274,3506,1277],{"href":1276},", we deployed these exact pre-rendered configurations and nested schemas. This resulted in immediate citations across major AI search indexes.",[26,3509,1294],{"id":1293},[1296,3511,3512,3518,3526,3534],{},[1236,3513,3514,3517],{},[1274,3515,2907],{"href":2905,"rel":3516},[1304]," — current GPTBot and OAI-SearchBot user agents",[1236,3519,3520,3525],{},[1274,3521,3524],{"href":3522,"rel":3523},"https:\u002F\u002Fsupport.anthropic.com\u002Fen\u002Farticles\u002F8896518-does-anthropic-crawl-data-from-the-web-and-how-can-site-owners-block-the-crawler",[1304],"Anthropic: how ClaudeBot works"," — official Claude crawler rules",[1236,3527,3528,3533],{},[1274,3529,3532],{"href":3530,"rel":3531},"https:\u002F\u002Fschema.org",[1304],"Schema.org"," — the full structured-data vocabulary",[1236,3535,3536,3541],{},[1274,3537,3540],{"href":3538,"rel":3539},"https:\u002F\u002Fdevelopers.google.com\u002Fsearch\u002Fdocs\u002Fcrawling-indexing\u002Frobots\u002Fintro",[1304],"Google: intro to robots.txt"," — directive syntax and precedence",[11,3543,3544,3545,62],{},"Structuring your technical SEO layer for AI scraping ensures that your site functions as an open, readable directory for the LLM agents dominating the next generation of web search. To learn how this technical layout integrates with a full AI-first marketing strategy, check out my introduction to ",[1274,3546,2386],{"href":3547},"\u002Fblog\u002Fgenerative-engine-optimization-geo",[11,3549,3550,3551,3554,3555,62],{},"If you need a professional developer to implement structured JSON-LD schemas, configure SSR routes, or audit your robots.txt setup, visit my ",[1274,3552,3553],{"href":2917},"GEO & SEO Services"," page or book a ",[1274,3556,2922],{"href":1328},[1332,3558,3559],{},"html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}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);}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}",{"title":40,"searchDepth":105,"depth":105,"links":3561},[3562,3563,3564,3567],{"id":3001,"depth":105,"text":3002},{"id":3020,"depth":105,"text":3021},{"id":3359,"depth":105,"text":3360,"children":3565},[3566],{"id":3373,"depth":112,"text":3374},{"id":1293,"depth":105,"text":1294},"2026-06-28","A technical masterclass on configuring robots.txt controls for AI agents, implementing nested JSON-LD schema graphs, and solving client-side rendering hurdles.",[3571,3574,3577],{"q":3572,"a":3573},"Should I allow every AI bot to crawl my site?","No. Separate bots by purpose: search and answer bots (GPTBot, OAI-SearchBot, ClaudeBot, PerplexityBot) are worth allowing — they bring citations and traffic. Pure training crawlers like CCBot can be blocked: they collect data without sending anything back.",{"q":3575,"a":3576},"Will AI crawlers see the content of my Vue or React SPA?","Not reliably. Some AI bots don't execute JavaScript, so a client-rendered app looks like an empty page to them. The fix is SSR or prerendering: the server must return complete HTML with text and markup.",{"q":3578,"a":3579},"Which JSON-LD markup matters most for AI search?","A linked @graph with Organization\u002FPerson (who you are), Service or Product (what you offer), and WebPage with dateModified. It's critical that the markup matches the visible content exactly — divergence undermines trust in the source.","\u002Fimages\u002Fblog\u002Fblog_ai_crawlers.jpg",{},"\u002Fblog\u002Fen\u002Fwebsite-optimization-ai-crawlers",{"title":2963,"description":3569},"blog\u002Fen\u002Fwebsite-optimization-ai-crawlers",[2956,2957,2997,3586,3587],"Schema","Technical","jymLTxFvhUq1G9stPtwy9EpBR6NrdTfbYFw6cv0jNcQ",{"id":3590,"title":3591,"body":3592,"date":3815,"description":3816,"extension":1348,"faq":3817,"image":3830,"lang":1360,"meta":3831,"navigation":108,"path":3832,"published":108,"readTime":167,"seo":3833,"stem":3834,"tags":3835,"updated":1346,"__hash__":3839},"blog\u002Fblog\u002Fen\u002Fsaas-mvp-development-cost.md","How Much Does SaaS MVP Development Cost in 2026: A Stage-by-Stage Estimate",{"type":8,"value":3593,"toc":3809},[3594,3597,3601,3604,3673,3680,3684,3687,3721,3725,3728,3759,3780,3784,3791,3798],[11,3595,3596],{},"Turnkey SaaS MVP development costs from $4,000 and takes 4–6 weeks — that buys a working product with registration, user dashboards, subscription billing, and an admin panel. A full platform with integrations costs more and takes 2–4 months. Below: which stages make up the estimate, where post-launch costs hide, and why a \"$1,000 MVP\" usually turns out to be the most expensive option.",[26,3598,3600],{"id":3599},"which-stages-make-up-the-price","Which stages make up the price?",[11,3602,3603],{},"My SaaS development process has four stages, and each is a separate line in the estimate:",[2422,3605,3606,3619],{},[2425,3607,3608],{},[2428,3609,3610,3613,3616],{},[2431,3611,3612],{"align":2433},"Stage",[2431,3614,3615],{"align":2433},"What happens",[2431,3617,3618],{"align":2433},"Budget share",[2442,3620,3621,3634,3647,3660],{},[2428,3622,3623,3628,3631],{},[2447,3624,3625],{"align":2433},[15,3626,3627],{},"1. Analysis & spec",[2447,3629,3630],{"align":2433},"Architecture design, database schema, stack selection, detailed specification",[2447,3632,3633],{"align":2433},"~10–15%",[2428,3635,3636,3641,3644],{},[2447,3637,3638],{"align":2433},[15,3639,3640],{},"2. MVP",[2447,3642,3643],{"align":2433},"Registration, user dashboards, the product core, Stripe billing",[2447,3645,3646],{"align":2433},"~50%",[2428,3648,3649,3654,3657],{},[2447,3650,3651],{"align":2433},[15,3652,3653],{},"3. Scaling",[2447,3655,3656],{"align":2433},"External API integrations, team accounts, pricing plans",[2447,3658,3659],{"align":2433},"~25%",[2428,3661,3662,3667,3670],{},[2447,3663,3664],{"align":2433},[15,3665,3666],{},"4. Launch",[2447,3668,3669],{"align":2433},"Server deployment, backups, monitoring, final testing",[2447,3671,3672],{"align":2433},"~10%",[11,3674,3675,3676,3679],{},"The key point: half the budget goes into the ",[15,3677,3678],{},"product core"," — the feature customers will pay for. If a contractor spends half the estimate on \"a beautiful landing page and animations,\" the product has a priorities problem.",[26,3681,3683],{"id":3682},"why-not-just-use-a-no-code-builder","Why not just use a no-code builder?",[11,3685,3686],{},"Sometimes you should: for validating demand without unique logic, no-code honestly saves months. Custom development becomes justified when at least one of these appears:",[1296,3688,3689,3695,3705,3715],{},[1236,3690,3691,3694],{},[15,3692,3693],{},"unique business logic"," — algorithms, calculations, automations that don't exist as ready-made blocks;",[1236,3696,3697,3700,3701,3704],{},[15,3698,3699],{},"subscriptions and complex billing"," — upgrades, grace periods, payment webhooks (I covered the technical side in ",[1274,3702,3703],{"href":1288},"my Stripe integration guide",");",[1236,3706,3707,3710,3711,3704],{},[15,3708,3709],{},"data requirements"," — tenant isolation, exports, regulatory compliance (see my breakdown of ",[1274,3712,3714],{"href":3713},"\u002Fblog\u002Fsaas-multitenancy-databases","multi-tenant architectures",[1236,3716,3717,3720],{},[15,3718,3719],{},"a scaling plan"," — builders hit a ceiling, and migrating off them costs more than building custom from the start.",[26,3722,3724],{"id":3723},"where-do-post-launch-costs-hide","Where do post-launch costs hide?",[11,3726,3727],{},"The development estimate is not the full cost of ownership. Budget for:",[1296,3729,3730,3736,3747,3753],{},[1236,3731,3732,3735],{},[15,3733,3734],{},"Infrastructure"," — a VPS or cloud, from tens of dollars a month at the start, growing with load.",[1236,3737,3738,3741,3742,443],{},[15,3739,3740],{},"Payment fees"," — Stripe takes a percentage of every transaction (",[1274,3743,3746],{"href":3744,"rel":3745},"https:\u002F\u002Fstripe.com\u002Fpricing",[1304],"Stripe pricing",[1236,3748,3749,3752],{},[15,3750,3751],{},"External APIs"," — email services, AI models, analytics: billed by usage.",[1236,3754,3755,3758],{},[15,3756,3757],{},"Iteration"," — a SaaS without updates dies; reserve budget for changes after the first user feedback.",[11,3760,3761,3762,357,3765,3770,3771,357,3774,3779],{},"Meanwhile the market keeps growing: global SaaS spending is forecast at ",[15,3763,3764],{},"$300B",[1274,3766,3769],{"href":3767,"rel":3768},"https:\u002F\u002Fwww.gartner.com\u002Fen\u002Fnewsroom\u002Fpress-releases\u002F2024-08-28-gartner-predicts-75-percent-of-enterprises-will-prioritize-backup-of-saas-applications-as-a-critical-requirement-by-2028",[1304],"Gartner, 2024","), and public SaaS companies trade at a median ",[15,3772,3773],{},"7.0x annual revenue",[1274,3775,3778],{"href":3776,"rel":3777},"https:\u002F\u002Fwww.saas-capital.com\u002Fthe-saas-capital-index\u002F",[1304],"SaaS Capital Index",") — a well-built product still has some of the best payback economics in the industry.",[26,3781,3783],{"id":3782},"why-is-the-cheap-mvp-the-most-expensive-one","Why is the \"cheap MVP\" the most expensive one?",[11,3785,3786,3787,62],{},"The main budgeting risk is not overpaying — it's paying twice. A $1,000 MVP from a bargain agency almost always means junior developers without code review, no tests, and template code that cannot scale. Within 3–6 months such a project gets rewritten from scratch — I broke down this trap with the math in ",[1274,3788,3790],{"href":3789},"\u002Fblog\u002Fsaas-development-cheap-code-trap","The price of cheap code",[11,3792,3793,3794,62],{},"What the right alternative looks like, step by step with architecture diagrams, is in my guide ",[1274,3795,3797],{"href":3796},"\u002Fblog\u002Fb2b-saas-mvp-30-days","How to build a B2B SaaS MVP in 30 days",[11,3799,3800,3801,3804,3805,3808],{},"Want an estimate for your idea? Describe the product on the ",[1274,3802,3803],{"href":3401},"custom SaaS development page"," — I reply within 24 hours with a budget and timeline. If the idea is still raw, start with a ",[1274,3806,3807],{"href":1328},"technical consultation",": in one hour we'll work through the architecture, the stack, and a realistic launch plan.",{"title":40,"searchDepth":105,"depth":105,"links":3810},[3811,3812,3813,3814],{"id":3599,"depth":105,"text":3600},{"id":3682,"depth":105,"text":3683},{"id":3723,"depth":105,"text":3724},{"id":3782,"depth":105,"text":3783},"2026-06-25","What a SaaS MVP price is made of — from $4,000 for a working product with billing. Stages, timelines, stack, and post-launch costs without marketing fluff.",[3818,3821,3824,3827],{"q":3819,"a":3820},"How long does SaaS MVP development take?","A working MVP with user dashboards and billing takes 4–6 weeks. A full platform with integrations, team accounts, and an admin panel takes 2–4 months. Timelines count from an approved specification.",{"q":3822,"a":3823},"What is included in the from-$4,000 price?","Architecture design and specification, MVP development with registration and user dashboards, subscription payments, an admin panel, server deployment, and monitoring setup.",{"q":3825,"a":3826},"Which stack do you use for SaaS projects?","Vue 3 \u002F Nuxt 3 on the frontend; Node.js, Python (FastAPI), or Laravel on the backend; PostgreSQL and Redis for data; Docker for deployment. The stack is chosen to fit the task, not the other way around.",{"q":3828,"a":3829},"What happens to the project after launch?","After launch I either take the project into maintenance (bug fixes, monitoring, feature development) or hand it over to your team with documentation: architecture, API, and deployment instructions.","\u002Fimages\u002Fblog\u002Fblog_saas_mvp_cost.jpg",{},"\u002Fblog\u002Fen\u002Fsaas-mvp-development-cost",{"title":3591,"description":3816},"blog\u002Fen\u002Fsaas-mvp-development-cost",[1368,3836,3837,3838],"MVP","Pricing","Startups","5JasrVO3lcuBGJ3yXDZH3dfjGqiUzpIm2XyohRQAvO4",{"id":3841,"title":3842,"body":3843,"date":4487,"description":4488,"extension":1348,"faq":4489,"image":4499,"lang":1360,"meta":4500,"navigation":108,"path":4501,"published":108,"readTime":215,"seo":4502,"stem":4503,"tags":4504,"updated":1346,"__hash__":4507},"blog\u002Fblog\u002Fen\u002Fb2b-saas-mvp-30-days.md","How to Build a B2B SaaS MVP in 30 Days Without Technical Debt",{"type":8,"value":3844,"toc":4469},[3845,3848,3855,3859,3862,3865,3871,3875,3878,3885,3889,3892,3916,3920,3931,3951,3955,3958,3962,3965,3969,3980,3990,3993,3997,4000,4004,4007,4011,4025,4031,4035,4038,4395,4399,4431,4433,4458,4466],[11,3846,3847],{},"Building a Minimum Viable Product (MVP) for a B2B SaaS is a delicate balancing act. Launch too slowly, and you risk missing the market window, running out of capital, or building features nobody wants. Build too quickly, and you might bury your project in a mountain of technical debt that makes future scaling, refactoring, and feature additions practically impossible.",[11,3849,3850,3851,3854],{},"In my years of developing custom SaaS products (like TeleGo.io and LingvoHabit), I’ve refined a ",[15,3852,3853],{},"30-day MVP blueprint",". It focuses on maximizing developer velocity and optimizing for AI crawlers (GEO) and search engines (SEO) without sacrificing clean code architecture. Here is a deep dive into building a production-ready, scalable B2B SaaS MVP in 30 days.",[26,3856,3858],{"id":3857},"the-core-technical-challenge-of-saas-mvps","The Core Technical Challenge of SaaS MVPs",[11,3860,3861],{},"Most B2B SaaS failures are not due to scaling issues like handling millions of concurrent requests. Instead, they fail because the codebase becomes a tangled \"spaghetti\" of dependencies where fixing one bug introduces three new ones.",[11,3863,3864],{},"To prevent this, you must choose a tech stack that enforces separation of concerns out of the box while allowing rapid prototyping.",[34,3866,3869],{"className":3867,"code":3868,"language":39,"meta":40},[37],"+--------------------------------------------------------+\n|                      Nuxt 3 App                        |\n|   (Universal SSR Frontend + Optimized SEO Head Meta)  |\n+---------------------------+----------------------------+\n                            | (HTTP\u002FJSON API)\n                            v\n+--------------------------------------------------------+\n|                Nitro Engine API Routes                 |\n|   (Secure Authentication -> Domain Services Logic)    |\n+---------------------------+----------------------------+\n                            | (Database Query)\n                            v\n+--------------------------------------------------------+\n|           SQLite Database (better-sqlite3)             |\n|  (Indexed tables, transaction safety, fast raw I\u002FO)  |\n+--------------------------------------------------------+\n",[42,3870,3868],{"__ignoreMap":40},[26,3872,3874],{"id":3873},"_1-the-lean-tech-stack-optimized-for-speed-and-seo","1. The Lean Tech Stack (Optimized for Speed and SEO)",[11,3876,3877],{},"For a 30-day launch, your stack must offer high developer velocity, out-of-the-box Server-Side Rendering (SSR), and a highly reliable database layer.",[11,3879,3880,3881,3884],{},"For example, when building the ",[1274,3882,3883],{"href":2871},"LingvoHabit MVP",", a multi-tenant language-learning platform, we utilized this exact frontend structure to ensure swift loading times and excellent search engine indexing.",[864,3886,3888],{"id":3887},"frontend-ssr-nuxt-3-vue-3-vite","Frontend & SSR: Nuxt 3 (Vue 3, Vite)",[11,3890,3891],{},"Nuxt 3 is the ultimate framework for modern SaaS development. It provides:",[1296,3893,3894,3900,3906],{},[1236,3895,3896,3899],{},[15,3897,3898],{},"Built-in Server-Side Rendering (SSR)",": Critical for traditional search engines (Google\u002FYandex) and AI search bots (Perplexity\u002FChatGPT) which need to scan static HTML to index and cite your brand.",[1236,3901,3902,3905],{},[15,3903,3904],{},"Automatic Routing",": Simplifies page management.",[1236,3907,3908,3911,3912,3915],{},[15,3909,3910],{},"Nitro Engine",": A fast server engine that lets you write backend API endpoints directly in ",[42,3913,3914],{},"\u002Fserver\u002Fapi"," using TypeScript.",[864,3917,3919],{"id":3918},"database-sqlite-via-better-sqlite3","Database: SQLite via better-sqlite3",[11,3921,3922,3923,3926,3927,3930],{},"Do not waste time setting up complex PostgreSQL clusters, AWS RDS instances, or Kubernetes pods in week one. Use ",[15,3924,3925],{},"SQLite"," via the ",[42,3928,3929],{},"better-sqlite3"," library.",[1296,3932,3933,3939,3945],{},[1236,3934,3935,3938],{},[15,3936,3937],{},"Performance",": SQLite runs in-memory or as a local file, performing transactions in microseconds. It easily handles 50-100 concurrent writes and thousands of reads per second, which is more than enough for an MVP.",[1236,3940,3941,3944],{},[15,3942,3943],{},"Portability",": Backing up your database is as simple as copying a single file.",[1236,3946,3947,3950],{},[15,3948,3949],{},"Scaling path",": When you hit limits, you can migrate to PostgreSQL in under an hour if you use an ORM or write standard SQL queries.",[26,3952,3954],{"id":3953},"_2-the-core-features-checklist","2. The Core Features Checklist",[11,3956,3957],{},"A B2B SaaS MVP only needs four functional modules to start onboarding and billing clients:",[864,3959,3961],{"id":3960},"a-authentication-session-based-or-passwordless","A. Authentication (Session-based or Passwordless)",[11,3963,3964],{},"Skip complex OAuth flows or custom identity providers. Implement passwordless magic links or standard email\u002Fpassword authentication using HTTP-only cookies to store JWT tokens securely.",[864,3966,3968],{"id":3967},"b-subscription-billing-stripe-integration","B. Subscription Billing (Stripe Integration)",[11,3970,3971,3972,3975,3976,3979],{},"Integrate ",[15,3973,3974],{},"Stripe Checkout"," for payment processing and ",[15,3977,3978],{},"Stripe Customer Portal"," to let users manage their subscriptions.",[11,3981,3982,3983,3986,3987,62],{},"For instance, in the ",[1274,3984,3985],{"href":1276},"TeleGo.io project",", we integrated this Stripe flow to handle hundreds of active subscriptions. For a deep technical dive on this, read my guide on ",[1274,3988,3989],{"href":1288},"Stripe Billing and Subscriptions Integration",[11,3991,3992],{},"This offloads the UI for card updates, billing history, and cancellations directly to Stripe, saving you at least 5 days of frontend development.",[864,3994,3996],{"id":3995},"c-the-core-value-loop","C. The Core Value Loop",[11,3998,3999],{},"This is the unique feature your clients are paying for (e.g., automated report generation, CRM data syncing, or AI text editing). Dedicate 50% of your time here.",[864,4001,4003],{"id":4002},"d-admin-telemetry-dashboard","D. Admin \u002F Telemetry Dashboard",[11,4005,4006],{},"A minimal interface for you (the founder) to track user signups, active subscriptions, and raw usage metrics.",[26,4008,4010],{"id":4009},"_3-designing-a-scalable-code-architecture-no-spaghetti","3. Designing a Scalable Code Architecture (No Spaghetti)",[11,4012,4013,4014,4017,4018,4021,4022,62],{},"To prevent a rewrite in month three, structure your server-side logic by ",[15,4015,4016],{},"business domains"," (modules) rather than technical layers. For example, group all user-related code inside a ",[42,4019,4020],{},"users"," domain. If you want to learn how to isolate data per client, check my article on ",[1274,4023,4024],{"href":3713},"SaaS Multi-Tenant Database Architectures",[34,4026,4029],{"className":4027,"code":4028,"language":39,"meta":40},[37],"server\u002F\n├── api\u002F\n│   ├── auth\u002F\n│   ├── billing\u002F\n│   └── projects\u002F\n├── database\u002F\n│   ├── connection.ts\n│   └── schema.sql\n└── services\u002F\n    ├── auth.service.ts\n    ├── stripe.service.ts\n    └── project.service.ts\n",[42,4030,4028],{"__ignoreMap":40},[864,4032,4034],{"id":4033},"implementing-code-isolation-stripe-service-example","Implementing Code Isolation: Stripe Service Example",[11,4036,4037],{},"Keep your business logic isolated from HTTP request handlers. Here is a professional TypeScript implementation of a Stripe subscription service:",[34,4039,4041],{"className":75,"code":4040,"language":77,"meta":40,"style":40},"\u002F\u002F server\u002Fservices\u002Fstripe.service.ts\nimport Stripe from 'stripe';\n\nconst stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {\n  apiVersion: '2023-10-16',\n});\n\nexport interface SubscriptionInput {\n  email: string;\n  priceId: string;\n  tenantId: string;\n}\n\nexport async function createCheckoutSession(input: SubscriptionInput): Promise\u003Cstring> {\n  const session = await stripe.checkout.sessions.create({\n    payment_method_types: ['card'],\n    line_items: [\n      {\n        price: input.priceId,\n        quantity: 1,\n      },\n    ],\n    mode: 'subscription',\n    success_url: `${process.env.APP_URL}\u002Fdashboard?billing=success`,\n    cancel_url: `${process.env.APP_URL}\u002Fdashboard?billing=cancel`,\n    customer_email: input.email,\n    metadata: {\n      tenantId: input.tenantId,\n    },\n  });\n\n  if (!session.url) {\n    throw new Error('Failed to generate Stripe checkout URL.');\n  }\n\n  return session.url;\n}\n",[42,4042,4043,4048,4062,4066,4092,4102,4106,4110,4122,4133,4144,4155,4159,4163,4199,4219,4230,4235,4240,4245,4254,4258,4263,4273,4299,4321,4326,4331,4336,4340,4344,4348,4359,4376,4380,4384,4391],{"__ignoreMap":40},[81,4044,4045],{"class":83,"line":84},[81,4046,4047],{"class":395},"\u002F\u002F server\u002Fservices\u002Fstripe.service.ts\n",[81,4049,4050,4052,4055,4057,4060],{"class":83,"line":105},[81,4051,88],{"class":87},[81,4053,4054],{"class":91}," Stripe ",[81,4056,95],{"class":87},[81,4058,4059],{"class":98}," 'stripe'",[81,4061,102],{"class":91},[81,4063,4064],{"class":83,"line":112},[81,4065,109],{"emptyLinePlaceholder":108},[81,4067,4068,4071,4074,4076,4078,4081,4084,4087,4089],{"class":83,"line":125},[81,4069,4070],{"class":87},"const",[81,4072,4073],{"class":135}," stripe",[81,4075,309],{"class":87},[81,4077,312],{"class":87},[81,4079,4080],{"class":118}," Stripe",[81,4082,4083],{"class":91},"(process.env.",[81,4085,4086],{"class":135},"STRIPE_SECRET_KEY",[81,4088,360],{"class":87},[81,4090,4091],{"class":91},", {\n",[81,4093,4094,4097,4100],{"class":83,"line":141},[81,4095,4096],{"class":91},"  apiVersion: ",[81,4098,4099],{"class":98},"'2023-10-16'",[81,4101,2612],{"class":91},[81,4103,4104],{"class":83,"line":154},[81,4105,3495],{"class":91},[81,4107,4108],{"class":83,"line":167},[81,4109,109],{"emptyLinePlaceholder":108},[81,4111,4112,4114,4117,4120],{"class":83,"line":179},[81,4113,262],{"class":87},[81,4115,4116],{"class":87}," interface",[81,4118,4119],{"class":118}," SubscriptionInput",[81,4121,122],{"class":91},[81,4123,4124,4127,4129,4131],{"class":83,"line":191},[81,4125,4126],{"class":128},"  email",[81,4128,132],{"class":87},[81,4130,149],{"class":135},[81,4132,102],{"class":91},[81,4134,4135,4138,4140,4142],{"class":83,"line":204},[81,4136,4137],{"class":128},"  priceId",[81,4139,132],{"class":87},[81,4141,149],{"class":135},[81,4143,102],{"class":91},[81,4145,4146,4149,4151,4153],{"class":83,"line":210},[81,4147,4148],{"class":128},"  tenantId",[81,4150,132],{"class":87},[81,4152,149],{"class":135},[81,4154,102],{"class":91},[81,4156,4157],{"class":83,"line":215},[81,4158,207],{"class":91},[81,4160,4161],{"class":83,"line":225},[81,4162,109],{"emptyLinePlaceholder":108},[81,4164,4165,4167,4170,4172,4175,4177,4180,4182,4184,4186,4188,4191,4193,4196],{"class":83,"line":237},[81,4166,262],{"class":87},[81,4168,4169],{"class":87}," async",[81,4171,265],{"class":87},[81,4173,4174],{"class":118}," createCheckoutSession",[81,4176,271],{"class":91},[81,4178,4179],{"class":128},"input",[81,4181,132],{"class":87},[81,4183,4119],{"class":118},[81,4185,291],{"class":91},[81,4187,132],{"class":87},[81,4189,4190],{"class":118}," Promise",[81,4192,1000],{"class":91},[81,4194,4195],{"class":135},"string",[81,4197,4198],{"class":91},"> {\n",[81,4200,4201,4203,4206,4208,4211,4214,4217],{"class":83,"line":249},[81,4202,303],{"class":87},[81,4204,4205],{"class":135}," session",[81,4207,309],{"class":87},[81,4209,4210],{"class":87}," await",[81,4212,4213],{"class":91}," stripe.checkout.sessions.",[81,4215,4216],{"class":118},"create",[81,4218,891],{"class":91},[81,4220,4221,4224,4227],{"class":83,"line":254},[81,4222,4223],{"class":91},"    payment_method_types: [",[81,4225,4226],{"class":98},"'card'",[81,4228,4229],{"class":91},"],\n",[81,4231,4232],{"class":83,"line":259},[81,4233,4234],{"class":91},"    line_items: [\n",[81,4236,4237],{"class":83,"line":300},[81,4238,4239],{"class":91},"      {\n",[81,4241,4242],{"class":83,"line":321},[81,4243,4244],{"class":91},"        price: input.priceId,\n",[81,4246,4247,4250,4252],{"class":83,"line":345},[81,4248,4249],{"class":91},"        quantity: ",[81,4251,2802],{"class":135},[81,4253,2612],{"class":91},[81,4255,4256],{"class":83,"line":351},[81,4257,3232],{"class":91},[81,4259,4260],{"class":83,"line":366},[81,4261,4262],{"class":91},"    ],\n",[81,4264,4265,4268,4271],{"class":83,"line":381},[81,4266,4267],{"class":91},"    mode: ",[81,4269,4270],{"class":98},"'subscription'",[81,4272,2612],{"class":91},[81,4274,4275,4278,4281,4284,4286,4289,4291,4294,4297],{"class":83,"line":387},[81,4276,4277],{"class":91},"    success_url: ",[81,4279,4280],{"class":98},"`${",[81,4282,4283],{"class":91},"process",[81,4285,62],{"class":98},[81,4287,4288],{"class":91},"env",[81,4290,62],{"class":98},[81,4292,4293],{"class":135},"APP_URL",[81,4295,4296],{"class":98},"}\u002Fdashboard?billing=success`",[81,4298,2612],{"class":91},[81,4300,4301,4304,4306,4308,4310,4312,4314,4316,4319],{"class":83,"line":392},[81,4302,4303],{"class":91},"    cancel_url: ",[81,4305,4280],{"class":98},[81,4307,4283],{"class":91},[81,4309,62],{"class":98},[81,4311,4288],{"class":91},[81,4313,62],{"class":98},[81,4315,4293],{"class":135},[81,4317,4318],{"class":98},"}\u002Fdashboard?billing=cancel`",[81,4320,2612],{"class":91},[81,4322,4323],{"class":83,"line":399},[81,4324,4325],{"class":91},"    customer_email: input.email,\n",[81,4327,4328],{"class":83,"line":452},[81,4329,4330],{"class":91},"    metadata: {\n",[81,4332,4333],{"class":83,"line":457},[81,4334,4335],{"class":91},"      tenantId: input.tenantId,\n",[81,4337,4338],{"class":83,"line":463},[81,4339,3161],{"class":91},[81,4341,4342],{"class":83,"line":476},[81,4343,1198],{"class":91},[81,4345,4346],{"class":83,"line":518},[81,4347,109],{"emptyLinePlaceholder":108},[81,4349,4350,4352,4354,4356],{"class":83,"line":538},[81,4351,354],{"class":87},[81,4353,357],{"class":91},[81,4355,360],{"class":87},[81,4357,4358],{"class":91},"session.url) {\n",[81,4360,4361,4364,4366,4369,4371,4374],{"class":83,"line":543},[81,4362,4363],{"class":87},"    throw",[81,4365,312],{"class":87},[81,4367,4368],{"class":118}," Error",[81,4370,271],{"class":91},[81,4372,4373],{"class":98},"'Failed to generate Stripe checkout URL.'",[81,4375,342],{"class":91},[81,4377,4378],{"class":83,"line":549},[81,4379,384],{"class":91},[81,4381,4382],{"class":83,"line":555},[81,4383,109],{"emptyLinePlaceholder":108},[81,4385,4386,4388],{"class":83,"line":568},[81,4387,1207],{"class":87},[81,4389,4390],{"class":91}," session.url;\n",[81,4392,4393],{"class":83,"line":588},[81,4394,207],{"class":91},[26,4396,4398],{"id":4397},"_4-launching-in-30-days-the-timeline","4. Launching in 30 Days: The Timeline",[1296,4400,4401,4407,4413,4419,4425],{},[1236,4402,4403,4406],{},[15,4404,4405],{},"Days 1–7 (Foundation)",": Schema migrations, basic router layouts, and session authentication using HTTP-only cookies.",[1236,4408,4409,4412],{},[15,4410,4411],{},"Days 8–18 (Core Value)",": Build the primary feature (e.g., generating PDFs, scheduling posts) and hook it up to the UI.",[1236,4414,4415,4418],{},[15,4416,4417],{},"Days 19–24 (Monetization)",": Integrate Stripe Checkout sessions, configure Stripe webhooks, and map webhook events to database subscription states.",[1236,4420,4421,4424],{},[15,4422,4423],{},"Days 25–28 (Quality & Hardening)",": Write unit tests for critical business logic (e.g., checking if user has billing limits) and configure server monitoring.",[1236,4426,4427,4430],{},[15,4428,4429],{},"Days 29–30 (Deployment & Analytics)",": Deploy to VPS using Docker, configure SSL, set up Yandex Metrika\u002FGoogle Analytics, and launch.",[26,4432,1294],{"id":1293},[1296,4434,4435,4443,4451],{},[1236,4436,4437,4442],{},[1274,4438,4441],{"href":4439,"rel":4440},"https:\u002F\u002Fnuxt.com\u002Fdocs",[1304],"Nuxt documentation"," — SSR, routing, and Nitro server endpoints",[1236,4444,4445,4450],{},[1274,4446,4449],{"href":4447,"rel":4448},"https:\u002F\u002Fwww.sqlite.org\u002Fwhentouse.html",[1304],"SQLite: Appropriate Uses"," — official guidance on when SQLite fits production",[1236,4452,4453,4457],{},[1274,4454,3974],{"href":4455,"rel":4456},"https:\u002F\u002Fdocs.stripe.com\u002Fpayments\u002Fcheckout",[1304]," — the hosted payment flow used in the example above",[11,4459,4460,4461,2919,4463,4465],{},"If you are planning to build a high-performance B2B SaaS MVP and need an experienced senior architect to launch it within weeks, explore my ",[1274,4462,3041],{"href":3401},[1274,4464,2922],{"href":1328}," to audit your product architecture.",[1332,4467,4468],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}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 .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":4470},[4471,4472,4476,4482,4485,4486],{"id":3857,"depth":105,"text":3858},{"id":3873,"depth":105,"text":3874,"children":4473},[4474,4475],{"id":3887,"depth":112,"text":3888},{"id":3918,"depth":112,"text":3919},{"id":3953,"depth":105,"text":3954,"children":4477},[4478,4479,4480,4481],{"id":3960,"depth":112,"text":3961},{"id":3967,"depth":112,"text":3968},{"id":3995,"depth":112,"text":3996},{"id":4002,"depth":112,"text":4003},{"id":4009,"depth":105,"text":4010,"children":4483},[4484],{"id":4033,"depth":112,"text":4034},{"id":4397,"depth":105,"text":4398},{"id":1293,"depth":105,"text":1294},"2026-06-21","A practical guide for startup founders on launching a scalable B2B SaaS product quickly using a modern, lean tech stack, detailed backend modules, and optimal database layouts.",[4490,4493,4496],{"q":4491,"a":4492},"Can I really run production on SQLite?","Yes. SQLite is production-ready: with WAL (Write-Ahead Logging) mode enabled it reads faster than many client-server databases. For a bootstrapped startup validating hypotheses it's an ideal choice that minimizes hosting costs.",{"q":4494,"a":4495},"When should I migrate to PostgreSQL?","When you need horizontal scaling (multiple app servers — SQLite lives on a single disk) or when the database grows beyond ~100 GB. With an ORM or standard SQL, the migration takes hours, not weeks.",{"q":4497,"a":4498},"How much does an MVP of this scope cost?","In my practice, an MVP with authentication, Stripe billing, and an admin panel costs from $4,000 and takes 4–6 weeks from an approved specification. Half the budget goes into the product core — the feature customers pay for.","\u002Fimages\u002Fblog\u002Fblog_saas_mvp.jpg",{},"\u002Fblog\u002Fen\u002Fb2b-saas-mvp-30-days",{"title":3842,"description":4488},"blog\u002Fen\u002Fb2b-saas-mvp-30-days",[1368,3836,4505,4506,1369],"Architecture","Nuxt","X2fVnjHGEN9evrRkvaK_1cSW4zL1CrlJg3RqVAUinMI",{"id":4509,"title":4510,"body":4511,"date":4789,"description":4790,"extension":1348,"faq":4791,"image":4801,"lang":1360,"meta":4802,"navigation":108,"path":4803,"published":108,"readTime":215,"seo":4804,"stem":4805,"tags":4806,"updated":1346,"__hash__":4811},"blog\u002Fblog\u002Fen\u002Fsaas-development-cheap-code-trap.md","The Trap of Cheap Development: When to Rewrite Your SaaS MVP",{"type":8,"value":4512,"toc":4780},[4513,4516,4519,4522,4525,4529,4532,4552,4559,4563,4566,4642,4646,4652,4658,4661,4699,4703,4721,4725,4728,4753,4755,4772],[11,4514,4515],{},"Minimizing initial development costs is a primary goal for almost every bootstrapped startup founder. This desire often leads to hiring cheap freelance agencies or low-cost offshore developers to assemble the first version (MVP) of their SaaS platform.",[11,4517,4518],{},"The promise is attractive: a full-featured B2B application delivered for a fraction of the cost of a senior developer’s salary.",[11,4520,4521],{},"However, a high percentage of these projects run into a technical wall shortly after launch. Adding a simple dropdown menu breaks the billing integration, the application load time exceeds 8 seconds, and new developers refuse to touch the codebase, advising you to \"rewrite everything from scratch.\"",[11,4523,4524],{},"In this management guide, we will analyze why the cheap development trap occurs, outline a technical rubric to diagnose if your codebase is salvageable, and look at the mathematical formula to choose between refactoring and rebuilding.",[26,4526,4528],{"id":4527},"the-economics-of-low-cost-development","The Economics of Low-Cost Development",[11,4530,4531],{},"When an agency charges $20-$30\u002Fhour to build a complex SaaS, they must optimize their own margins to survive. They achieve this in three ways:",[1233,4533,4534,4540,4546],{},[1236,4535,4536,4539],{},[15,4537,4538],{},"Hiring Junior Developers",": The project is designed and written by engineers with minimal architectural experience.",[1236,4541,4542,4545],{},[15,4543,4544],{},"Skipping Quality Controls",": Zero unit tests, integration tests, or API documentations are created. The only quality assurance is manual testing by the developers themselves.",[1236,4547,4548,4551],{},[15,4549,4550],{},"Using Rigid Boilerplates",": The application is built on top of outdated templates or custom-patched WordPress\u002FNo-Code systems that are impossible to scale or integrate with modern APIs.",[11,4553,4554,4555,4558],{},"The result is massive ",[15,4556,4557],{},"technical debt"," accumulated from day one. You save money upfront, but you pay a high premium in developer friction and slow features velocity post-launch.",[26,4560,4562],{"id":4561},"_1-technical-health-rubric","1. Technical Health Rubric",[11,4564,4565],{},"To make an objective decision rather than relying on gut feelings, perform a technical check of your MVP against these key metrics:",[2422,4567,4568,4581],{},[2425,4569,4570],{},[2428,4571,4572,4575,4578],{},[2431,4573,4574],{"align":2433},"Engineering Layer",[2431,4576,4577],{"align":2433},"Refactoring is Viable",[2431,4579,4580],{"align":2433},"Rewrite is Mandatory",[2442,4582,4583,4596,4609,4625],{},[2428,4584,4585,4590,4593],{},[2447,4586,4587],{"align":2433},[15,4588,4589],{},"Database Structure",[2447,4591,4592],{"align":2433},"Relational schemas are normalized, with indexing on primary keys and foreign keys.",[2447,4594,4595],{"align":2433},"No foreign keys, massive duplicate data columns, lack of transactional integrity.",[2428,4597,4598,4603,4606],{},[2447,4599,4600],{"align":2433},[15,4601,4602],{},"Code Isolation",[2447,4604,4605],{"align":2433},"Logic is separated into controllers or services; UI components only handle display.",[2447,4607,4608],{"align":2433},"Raw SQL queries or API credentials (like Stripe secret keys) are written inside client-side components.",[2428,4610,4611,4616,4619],{},[2447,4612,4613],{"align":2433},[15,4614,4615],{},"Dependency Health",[2447,4617,4618],{"align":2433},"Standard packages (e.g. standard Node.js libraries) that can be upgraded via npm.",[2447,4620,4621,4622,62],{"align":2433},"Proprietary, discontinued, or undocumented modules modified manually inside ",[42,4623,4624],{},"node_modules",[2428,4626,4627,4632,4635],{},[2447,4628,4629],{"align":2433},[15,4630,4631],{},"Error Handling",[2447,4633,4634],{"align":2433},"Errors are caught, logged, and return clean HTTP status codes (400, 401, 500).",[2447,4636,4637,4638,4641],{"align":2433},"Empty catch blocks (",[42,4639,4640],{},"try {} catch(e) {}",") that hide bugs, application crashes silently.",[26,4643,4645],{"id":4644},"_2-refactor-vs-rebuild-the-financial-calculation","2. Refactor vs. Rebuild: The Financial Calculation",[11,4647,4648,4649,132],{},"To determine the best path forward, calculate the ",[15,4650,4651],{},"Refactoring Coefficient (K)",[34,4653,4656],{"className":4654,"code":4655,"language":39},[37],"K = (C_audit + C_tests + C_db + C_logic) \u002F C_rebuild\n",[42,4657,4655],{"__ignoreMap":40},[11,4659,4660],{},"Where:",[1296,4662,4663,4669,4675,4681,4687,4693],{},[1236,4664,4665,4668],{},[15,4666,4667],{},"K",": Refactoring Coefficient (the threshold of feasibility).",[1236,4670,4671,4674],{},[15,4672,4673],{},"C_audit",": Cost of auditing and identifying all hidden bugs in the current legacy code.",[1236,4676,4677,4680],{},[15,4678,4679],{},"C_tests",": Cost of writing unit\u002Fintegration tests to ensure refactoring does not break existing features.",[1236,4682,4683,4686],{},[15,4684,4685],{},"C_db",": Cost of migrating and fixing the database schema without losing existing customer records.",[1236,4688,4689,4692],{},[15,4690,4691],{},"C_logic",": Cost of isolating business logic into clean services.",[1236,4694,4695,4698],{},[15,4696,4697],{},"C_rebuild",": Cost of rebuilding the same feature set from scratch using a modern, scalable architecture (like Nuxt 3 + Node.js).",[864,4700,4702],{"id":4701},"the-decision-rule","The Decision Rule:",[1296,4704,4705,4712],{},[1236,4706,4707,4708,4711],{},"If ",[15,4709,4710],{},"K \u003C 0.5",": Refactoring is the logical choice. The foundation is solid; it just needs cleanup.",[1236,4713,4707,4714,2606,4717,4720],{},[15,4715,4716],{},"K >= 0.7",[15,4718,4719],{},"Rewrite the application",". Attempting to patch a deeply flawed codebase will cost more time and money than starting fresh. It is similar to trying to fix a crumbling house foundation—you will spend more patching cracks than you would rebuilding.",[26,4722,4724],{"id":4723},"_3-how-to-protect-your-next-development-run","3. How to Protect Your Next Development Run",[11,4726,4727],{},"If you decide to rebuild, implement these checks to prevent falling into the same trap again:",[1296,4729,4730,4736,4742],{},[1236,4731,4732,4735],{},[15,4733,4734],{},"Establish Code Ownership",": Ensure all source code is hosted on your GitHub organization from day one, with branch protection rules requiring code reviews before merge.",[1236,4737,4738,4741],{},[15,4739,4740],{},"Mandate Static Analysis",": Require the team to use automated tools (like ESLint or Biome) and maintain at least 70% test coverage on critical business logic.",[1236,4743,4744,4747,4748,4752],{},[15,4745,4746],{},"Perform Early Audits",": Do not wait until week 20 to review the code. Hire an independent technical consultant to perform a code audit in week 2 or 3 of development to catch architectural mistakes early; you can read my complete ",[1274,4749,4751],{"href":4750},"\u002Fblog\u002Fcode-audit-technical-debt","Codebase Audit Checklist"," to learn how to structure this check.",[26,4754,1294],{"id":1293},[1296,4756,4757,4765],{},[1236,4758,4759,4764],{},[1274,4760,4763],{"href":4761,"rel":4762},"https:\u002F\u002Fmartinfowler.com\u002Fbliki\u002FTechnicalDebt.html",[1304],"Martin Fowler: Technical Debt"," — the canonical definition of technical debt and its quadrants",[1236,4766,4767,4771],{},[1274,4768,4770],{"href":2569,"rel":4769},[1304],"OWASP Top 10"," — the vulnerabilities most often found in \"cheap\" codebases",[11,4773,4774,4775,3554,4777,4779],{},"If you are dealing with a slow, bug-ridden MVP, struggling with low developer velocity, or need a professional assessment of your codebase's health, visit my ",[1274,4776,3041],{"href":3401},[1274,4778,2922],{"href":1328}," to receive an audit.",{"title":40,"searchDepth":105,"depth":105,"links":4781},[4782,4783,4784,4787,4788],{"id":4527,"depth":105,"text":4528},{"id":4561,"depth":105,"text":4562},{"id":4644,"depth":105,"text":4645,"children":4785},[4786],{"id":4701,"depth":112,"text":4702},{"id":4723,"depth":105,"text":4724},{"id":1293,"depth":105,"text":1294},"2026-06-18","Why hiring low-cost outsourcing agencies often results in rewriting your B2B SaaS MVP from scratch, and how to mathematically evaluate if a codebase is salvageable.",[4792,4795,4798],{"q":4793,"a":4794},"How do I quickly tell whether an MVP will need a rewrite?","Four red flags: no relations or indexes in the database, business logic and secret keys sitting inside client-side components, dependencies that are abandoned or hand-patched in node_modules, and errors silenced by empty try\u002Fcatch blocks with no logging. Two or more flags almost always means a rewrite.",{"q":4796,"a":4797},"When is refactoring cheaper than rebuilding?","When the combined cost of the audit, tests, database fixes, and logic extraction is under half the cost of building from scratch (coefficient K_R \u003C 0.5). At K_R ≥ 0.7 a rewrite wins: patching a crumbling foundation consumes more resources than pouring a new one.",{"q":4799,"a":4800},"How do I avoid the same trap with the next contractor?","Keep the code in your own repository with protected main and mandatory reviews, require linters and 70%+ test coverage on critical logic, and order an independent architecture audit in week 2–3 of development — not after release.","\u002Fimages\u002Fblog\u002Fblog_cheap_code_trap.jpg",{},"\u002Fblog\u002Fen\u002Fsaas-development-cheap-code-trap",{"title":4510,"description":4790},"blog\u002Fen\u002Fsaas-development-cheap-code-trap",[1368,4807,4808,4809,4810],"Startup","Consultations","Management","Outsourcing","G5MNT1iy2-Q-ifyR0epGLf1ggrfwOySsWqQzlr2JyHA",{"id":4813,"title":4814,"body":4815,"date":6138,"description":6139,"extension":1348,"faq":6140,"image":6150,"lang":1360,"meta":6151,"navigation":108,"path":6152,"published":108,"readTime":215,"seo":6153,"stem":6154,"tags":6155,"updated":1346,"__hash__":6159},"blog\u002Fblog\u002Fen\u002Fsaas-stripe-billing-integration.md","SaaS Monetization: Implementing Stripe, Billing, and Subscriptions Without Pain",{"type":8,"value":4816,"toc":6132},[4817,4820,4823,4826,4830,4833,4839,4869,4873,4888,4891,6045,6049,6076,6086,6088,6114,6120,6129],[11,4818,4819],{},"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,4821,4822],{},"If a webhook fails or is handled out of order, you might block paying users or grant free access to canceled subscribers.",[11,4824,4825],{},"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,4827,4829],{"id":4828},"_1-the-anatomy-of-subscription-lifecycles","1. The Anatomy of Subscription Lifecycles",[11,4831,4832],{},"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,4834,4837],{"className":4835,"code":4836,"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,4838,4836],{"__ignoreMap":40},[1296,4840,4841,4847,4853,4863],{},[1236,4842,4843,4846],{},[42,4844,4845],{},"trialing",": User has full access without being charged yet.",[1236,4848,4849,4852],{},[42,4850,4851],{},"active",": Successful payment. Full access.",[1236,4854,4855,4858,4859,4862],{},[42,4856,4857],{},"past_due",": The card charge failed. ",[2414,4860,4861],{},"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,4864,4865,4868],{},[42,4866,4867],{},"canceled",": Access is revoked. The user must navigate to the portal to resubscribe.",[26,4870,4872],{"id":4871},"_2-implementing-a-secure-production-ready-webhook-handler","2. Implementing a Secure, Production-Ready Webhook Handler",[11,4874,4875,4876,4879,4880,4883,4884,4887],{},"Stripe sends asynchronous events to your API using HTTP POST requests. Because anyone can forge an HTTP request to your ",[42,4877,4878],{},"\u002Fapi\u002Fbilling\u002Fwebhook"," endpoint, you ",[15,4881,4882],{},"must verify the cryptographic signature"," sent by Stripe in the ",[42,4885,4886],{},"stripe-signature"," header.",[11,4889,4890],{},"Here is a robust implementation using Express and TypeScript:",[34,4892,4894],{"className":75,"code":4893,"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,4895,4896,4910,4922,4926,4946,4954,4958,4962,4976,4995,4999,5004,5009,5020,5027,5044,5084,5102,5107,5118,5146,5150,5154,5173,5177,5184,5189,5205,5224,5248,5279,5283,5287,5293,5301,5312,5321,5345,5356,5363,5367,5376,5396,5405,5411,5415,5424,5446,5456,5462,5466,5473,5498,5502,5506,5521,5537,5558,5580,5584,5588,5592,5597,5622,5635,5647,5673,5678,5683,5699,5718,5744,5749,5755,5769,5775,5781,5789,5795,5801,5806,5831,5836,5841,5864,5875,5892,5897,5908,5913,5919,5927,5933,5938,5943,5961,5966,5971,5995,6014,6020,6040],{"__ignoreMap":40},[81,4897,4898,4900,4903,4905,4908],{"class":83,"line":84},[81,4899,88],{"class":87},[81,4901,4902],{"class":91}," express ",[81,4904,95],{"class":87},[81,4906,4907],{"class":98}," 'express'",[81,4909,102],{"class":91},[81,4911,4912,4914,4916,4918,4920],{"class":83,"line":105},[81,4913,88],{"class":87},[81,4915,4054],{"class":91},[81,4917,95],{"class":87},[81,4919,4059],{"class":98},[81,4921,102],{"class":91},[81,4923,4924],{"class":83,"line":112},[81,4925,109],{"emptyLinePlaceholder":108},[81,4927,4928,4930,4932,4934,4936,4938,4940,4942,4944],{"class":83,"line":125},[81,4929,4070],{"class":87},[81,4931,4073],{"class":135},[81,4933,309],{"class":87},[81,4935,312],{"class":87},[81,4937,4080],{"class":118},[81,4939,4083],{"class":91},[81,4941,4086],{"class":135},[81,4943,360],{"class":87},[81,4945,4091],{"class":91},[81,4947,4948,4950,4952],{"class":83,"line":141},[81,4949,4096],{"class":91},[81,4951,4099],{"class":98},[81,4953,2612],{"class":91},[81,4955,4956],{"class":83,"line":154},[81,4957,3495],{"class":91},[81,4959,4960],{"class":83,"line":167},[81,4961,109],{"emptyLinePlaceholder":108},[81,4963,4964,4966,4969,4971,4974],{"class":83,"line":179},[81,4965,4070],{"class":87},[81,4967,4968],{"class":135}," app",[81,4970,309],{"class":87},[81,4972,4973],{"class":118}," express",[81,4975,449],{"class":91},[81,4977,4978,4980,4983,4985,4988,4991,4993],{"class":83,"line":191},[81,4979,4070],{"class":87},[81,4981,4982],{"class":135}," endpointSecret",[81,4984,309],{"class":87},[81,4986,4987],{"class":91}," process.env.",[81,4989,4990],{"class":135},"STRIPE_WEBHOOK_SECRET",[81,4992,360],{"class":87},[81,4994,102],{"class":91},[81,4996,4997],{"class":83,"line":204},[81,4998,109],{"emptyLinePlaceholder":108},[81,5000,5001],{"class":83,"line":210},[81,5002,5003],{"class":395},"\u002F\u002F IMPORTANT: Webhook signature verification requires the raw request body buffer.\n",[81,5005,5006],{"class":83,"line":215},[81,5007,5008],{"class":395},"\u002F\u002F Do not parse the body with standard express.json() parser before this route.\n",[81,5010,5011,5014,5017],{"class":83,"line":225},[81,5012,5013],{"class":91},"app.",[81,5015,5016],{"class":118},"post",[81,5018,5019],{"class":91},"(\n",[81,5021,5022,5025],{"class":83,"line":237},[81,5023,5024],{"class":98},"  '\u002Fapi\u002Fbilling\u002Fwebhook'",[81,5026,2612],{"class":91},[81,5028,5029,5032,5035,5038,5041],{"class":83,"line":249},[81,5030,5031],{"class":91},"  express.",[81,5033,5034],{"class":118},"raw",[81,5036,5037],{"class":91},"({ type: ",[81,5039,5040],{"class":98},"'application\u002Fjson'",[81,5042,5043],{"class":91}," }),\n",[81,5045,5046,5049,5051,5054,5056,5058,5060,5063,5065,5068,5070,5072,5074,5077,5080,5082],{"class":83,"line":254},[81,5047,5048],{"class":87},"  async",[81,5050,357],{"class":91},[81,5052,5053],{"class":128},"req",[81,5055,132],{"class":87},[81,5057,4973],{"class":118},[81,5059,62],{"class":91},[81,5061,5062],{"class":118},"Request",[81,5064,281],{"class":91},[81,5066,5067],{"class":128},"res",[81,5069,132],{"class":87},[81,5071,4973],{"class":118},[81,5073,62],{"class":91},[81,5075,5076],{"class":118},"Response",[81,5078,5079],{"class":91},") ",[81,5081,1026],{"class":87},[81,5083,122],{"class":91},[81,5085,5086,5088,5091,5093,5096,5099],{"class":83,"line":259},[81,5087,755],{"class":87},[81,5089,5090],{"class":135}," sig",[81,5092,309],{"class":87},[81,5094,5095],{"class":91}," req.headers[",[81,5097,5098],{"class":98},"'stripe-signature'",[81,5100,5101],{"class":91},"];\n",[81,5103,5104],{"class":83,"line":300},[81,5105,5106],{"class":91},"    \n",[81,5108,5109,5111,5113,5115],{"class":83,"line":321},[81,5110,1054],{"class":87},[81,5112,357],{"class":91},[81,5114,360],{"class":87},[81,5116,5117],{"class":91},"sig) {\n",[81,5119,5120,5123,5126,5129,5131,5134,5136,5139,5141,5144],{"class":83,"line":345},[81,5121,5122],{"class":87},"      return",[81,5124,5125],{"class":91}," res.",[81,5127,5128],{"class":118},"status",[81,5130,271],{"class":91},[81,5132,5133],{"class":135},"400",[81,5135,443],{"class":91},[81,5137,5138],{"class":118},"send",[81,5140,271],{"class":91},[81,5142,5143],{"class":98},"'Missing stripe-signature header.'",[81,5145,342],{"class":91},[81,5147,5148],{"class":83,"line":351},[81,5149,1193],{"class":91},[81,5151,5152],{"class":83,"line":366},[81,5153,109],{"emptyLinePlaceholder":108},[81,5155,5156,5159,5162,5164,5166,5168,5171],{"class":83,"line":381},[81,5157,5158],{"class":87},"    let",[81,5160,5161],{"class":91}," event",[81,5163,132],{"class":87},[81,5165,4080],{"class":118},[81,5167,62],{"class":91},[81,5169,5170],{"class":118},"Event",[81,5172,102],{"class":91},[81,5174,5175],{"class":83,"line":387},[81,5176,109],{"emptyLinePlaceholder":108},[81,5178,5179,5182],{"class":83,"line":392},[81,5180,5181],{"class":87},"    try",[81,5183,122],{"class":91},[81,5185,5186],{"class":83,"line":399},[81,5187,5188],{"class":395},"      \u002F\u002F Validate signature against Stripe webhook secret\n",[81,5190,5191,5194,5196,5199,5202],{"class":83,"line":452},[81,5192,5193],{"class":91},"      event ",[81,5195,1093],{"class":87},[81,5197,5198],{"class":91}," stripe.webhooks.",[81,5200,5201],{"class":118},"constructEvent",[81,5203,5204],{"class":91},"(req.body, sig, endpointSecret);\n",[81,5206,5207,5210,5212,5214,5217,5219,5221],{"class":83,"line":457},[81,5208,5209],{"class":91},"    } ",[81,5211,831],{"class":87},[81,5213,357],{"class":91},[81,5215,5216],{"class":128},"err",[81,5218,132],{"class":87},[81,5220,1046],{"class":135},[81,5222,5223],{"class":91},") {\n",[81,5225,5226,5229,5232,5234,5237,5239,5241,5244,5246],{"class":83,"line":463},[81,5227,5228],{"class":91},"      console.",[81,5230,5231],{"class":118},"error",[81,5233,271],{"class":91},[81,5235,5236],{"class":98},"`[Webhook Security Error] Signature verification failed: ${",[81,5238,5216],{"class":91},[81,5240,62],{"class":98},[81,5242,5243],{"class":91},"message",[81,5245,512],{"class":98},[81,5247,342],{"class":91},[81,5249,5250,5252,5254,5256,5258,5260,5262,5264,5266,5269,5271,5273,5275,5277],{"class":83,"line":476},[81,5251,5122],{"class":87},[81,5253,5125],{"class":91},[81,5255,5128],{"class":118},[81,5257,271],{"class":91},[81,5259,5133],{"class":135},[81,5261,443],{"class":91},[81,5263,5138],{"class":118},[81,5265,271],{"class":91},[81,5267,5268],{"class":98},"`Webhook Error: ${",[81,5270,5216],{"class":91},[81,5272,62],{"class":98},[81,5274,5243],{"class":91},[81,5276,512],{"class":98},[81,5278,342],{"class":91},[81,5280,5281],{"class":83,"line":518},[81,5282,1193],{"class":91},[81,5284,5285],{"class":83,"line":538},[81,5286,109],{"emptyLinePlaceholder":108},[81,5288,5289,5291],{"class":83,"line":543},[81,5290,5181],{"class":87},[81,5292,122],{"class":91},[81,5294,5295,5298],{"class":83,"line":549},[81,5296,5297],{"class":87},"      switch",[81,5299,5300],{"class":91}," (event.type) {\n",[81,5302,5303,5306,5309],{"class":83,"line":555},[81,5304,5305],{"class":87},"        case",[81,5307,5308],{"class":98}," 'customer.subscription.created'",[81,5310,5311],{"class":91},":\n",[81,5313,5314,5316,5319],{"class":83,"line":568},[81,5315,5305],{"class":87},[81,5317,5318],{"class":98}," 'customer.subscription.updated'",[81,5320,2668],{"class":91},[81,5322,5323,5326,5329,5331,5334,5336,5338,5340,5343],{"class":83,"line":588},[81,5324,5325],{"class":87},"          const",[81,5327,5328],{"class":135}," subscription",[81,5330,309],{"class":87},[81,5332,5333],{"class":91}," event.data.object ",[81,5335,1043],{"class":87},[81,5337,4080],{"class":118},[81,5339,62],{"class":91},[81,5341,5342],{"class":118},"Subscription",[81,5344,102],{"class":91},[81,5346,5347,5350,5353],{"class":83,"line":599},[81,5348,5349],{"class":87},"          await",[81,5351,5352],{"class":118}," handleSubscriptionUpdate",[81,5354,5355],{"class":91},"(subscription);\n",[81,5357,5358,5361],{"class":83,"line":609},[81,5359,5360],{"class":87},"          break",[81,5362,102],{"class":91},[81,5364,5365],{"class":83,"line":614},[81,5366,3340],{"class":91},[81,5368,5369,5371,5374],{"class":83,"line":620},[81,5370,5305],{"class":87},[81,5372,5373],{"class":98}," 'customer.subscription.deleted'",[81,5375,2668],{"class":91},[81,5377,5378,5380,5382,5384,5386,5388,5390,5392,5394],{"class":83,"line":632},[81,5379,5325],{"class":87},[81,5381,5328],{"class":135},[81,5383,309],{"class":87},[81,5385,5333],{"class":91},[81,5387,1043],{"class":87},[81,5389,4080],{"class":118},[81,5391,62],{"class":91},[81,5393,5342],{"class":118},[81,5395,102],{"class":91},[81,5397,5398,5400,5403],{"class":83,"line":646},[81,5399,5349],{"class":87},[81,5401,5402],{"class":118}," handleSubscriptionDeletion",[81,5404,5355],{"class":91},[81,5406,5407,5409],{"class":83,"line":656},[81,5408,5360],{"class":87},[81,5410,102],{"class":91},[81,5412,5413],{"class":83,"line":670},[81,5414,3340],{"class":91},[81,5416,5417,5419,5422],{"class":83,"line":675},[81,5418,5305],{"class":87},[81,5420,5421],{"class":98}," 'invoice.payment_failed'",[81,5423,2668],{"class":91},[81,5425,5426,5428,5431,5433,5435,5437,5439,5441,5444],{"class":83,"line":681},[81,5427,5325],{"class":87},[81,5429,5430],{"class":135}," invoice",[81,5432,309],{"class":87},[81,5434,5333],{"class":91},[81,5436,1043],{"class":87},[81,5438,4080],{"class":118},[81,5440,62],{"class":91},[81,5442,5443],{"class":118},"Invoice",[81,5445,102],{"class":91},[81,5447,5448,5450,5453],{"class":83,"line":700},[81,5449,5349],{"class":87},[81,5451,5452],{"class":118}," handlePaymentFailure",[81,5454,5455],{"class":91},"(invoice);\n",[81,5457,5458,5460],{"class":83,"line":705},[81,5459,5360],{"class":87},[81,5461,102],{"class":91},[81,5463,5464],{"class":83,"line":717},[81,5465,3340],{"class":91},[81,5467,5468,5471],{"class":83,"line":728},[81,5469,5470],{"class":87},"        default",[81,5472,5311],{"class":91},[81,5474,5475,5478,5481,5483,5486,5489,5491,5494,5496],{"class":83,"line":733},[81,5476,5477],{"class":91},"          console.",[81,5479,5480],{"class":118},"log",[81,5482,271],{"class":91},[81,5484,5485],{"class":98},"`[Stripe Webhook] Ignored event: ${",[81,5487,5488],{"class":91},"event",[81,5490,62],{"class":98},[81,5492,5493],{"class":91},"type",[81,5495,512],{"class":98},[81,5497,342],{"class":91},[81,5499,5500],{"class":83,"line":738},[81,5501,2849],{"class":91},[81,5503,5504],{"class":83,"line":744},[81,5505,109],{"emptyLinePlaceholder":108},[81,5507,5508,5511,5513,5516,5518],{"class":83,"line":752},[81,5509,5510],{"class":91},"      res.",[81,5512,2591],{"class":118},[81,5514,5515],{"class":91},"({ received: ",[81,5517,819],{"class":135},[81,5519,5520],{"class":91}," });\n",[81,5522,5523,5525,5527,5529,5531,5533,5535],{"class":83,"line":774},[81,5524,5209],{"class":91},[81,5526,831],{"class":87},[81,5528,357],{"class":91},[81,5530,5216],{"class":128},[81,5532,132],{"class":87},[81,5534,1046],{"class":135},[81,5536,5223],{"class":91},[81,5538,5539,5541,5543,5545,5548,5550,5552,5554,5556],{"class":83,"line":812},[81,5540,5228],{"class":91},[81,5542,5231],{"class":118},[81,5544,271],{"class":91},[81,5546,5547],{"class":98},"`[Webhook Error] Processing failed: ${",[81,5549,5216],{"class":91},[81,5551,62],{"class":98},[81,5553,5243],{"class":91},[81,5555,512],{"class":98},[81,5557,342],{"class":91},[81,5559,5560,5562,5564,5566,5569,5571,5573,5575,5578],{"class":83,"line":825},[81,5561,5510],{"class":91},[81,5563,5128],{"class":118},[81,5565,271],{"class":91},[81,5567,5568],{"class":135},"500",[81,5570,443],{"class":91},[81,5572,5138],{"class":118},[81,5574,271],{"class":91},[81,5576,5577],{"class":98},"'Internal Server Error'",[81,5579,342],{"class":91},[81,5581,5582],{"class":83,"line":837},[81,5583,1193],{"class":91},[81,5585,5586],{"class":83,"line":848},[81,5587,384],{"class":91},[81,5589,5590],{"class":83,"line":853},[81,5591,342],{"class":91},[81,5593,5595],{"class":83,"line":5594},62,[81,5596,109],{"emptyLinePlaceholder":108},[81,5598,5600,5603,5605,5607,5609,5612,5614,5616,5618,5620],{"class":83,"line":5599},63,[81,5601,5602],{"class":87},"async",[81,5604,265],{"class":87},[81,5606,5352],{"class":118},[81,5608,271],{"class":91},[81,5610,5611],{"class":128},"sub",[81,5613,132],{"class":87},[81,5615,4080],{"class":118},[81,5617,62],{"class":91},[81,5619,5342],{"class":118},[81,5621,5223],{"class":91},[81,5623,5625,5627,5630,5632],{"class":83,"line":5624},64,[81,5626,303],{"class":87},[81,5628,5629],{"class":135}," tenantId",[81,5631,309],{"class":87},[81,5633,5634],{"class":91}," sub.metadata.tenantId;\n",[81,5636,5638,5640,5642,5644],{"class":83,"line":5637},65,[81,5639,354],{"class":87},[81,5641,357],{"class":91},[81,5643,360],{"class":87},[81,5645,5646],{"class":91},"tenantId) {\n",[81,5648,5650,5652,5654,5656,5658,5661,5663,5665,5668,5671],{"class":83,"line":5649},66,[81,5651,4363],{"class":87},[81,5653,312],{"class":87},[81,5655,4368],{"class":118},[81,5657,271],{"class":91},[81,5659,5660],{"class":98},"`Subscription ${",[81,5662,5611],{"class":91},[81,5664,62],{"class":98},[81,5666,5667],{"class":91},"id",[81,5669,5670],{"class":98},"} is missing tenantId metadata.`",[81,5672,342],{"class":91},[81,5674,5676],{"class":83,"line":5675},67,[81,5677,384],{"class":91},[81,5679,5681],{"class":83,"line":5680},68,[81,5682,109],{"emptyLinePlaceholder":108},[81,5684,5686,5688,5691,5693,5696],{"class":83,"line":5685},69,[81,5687,303],{"class":87},[81,5689,5690],{"class":135}," status",[81,5692,309],{"class":87},[81,5694,5695],{"class":91}," sub.status; ",[81,5697,5698],{"class":395},"\u002F\u002F 'active', 'trialing', 'past_due', etc.\n",[81,5700,5702,5704,5707,5709,5712,5715],{"class":83,"line":5701},70,[81,5703,303],{"class":87},[81,5705,5706],{"class":135}," priceId",[81,5708,309],{"class":87},[81,5710,5711],{"class":91}," sub.items.data[",[81,5713,5714],{"class":135},"0",[81,5716,5717],{"class":91},"].price.id;\n",[81,5719,5721,5723,5726,5728,5730,5733,5736,5739,5742],{"class":83,"line":5720},71,[81,5722,303],{"class":87},[81,5724,5725],{"class":135}," currentPeriodEnd",[81,5727,309],{"class":87},[81,5729,312],{"class":87},[81,5731,5732],{"class":118}," Date",[81,5734,5735],{"class":91},"(sub.current_period_end ",[81,5737,5738],{"class":87},"*",[81,5740,5741],{"class":135}," 1000",[81,5743,342],{"class":91},[81,5745,5747],{"class":83,"line":5746},72,[81,5748,109],{"emptyLinePlaceholder":108},[81,5750,5752],{"class":83,"line":5751},73,[81,5753,5754],{"class":395},"  \u002F\u002F Sync to database\n",[81,5756,5758,5761,5764,5767],{"class":83,"line":5757},74,[81,5759,5760],{"class":87},"  await",[81,5762,5763],{"class":91}," db.",[81,5765,5766],{"class":118},"query",[81,5768,5019],{"class":91},[81,5770,5772],{"class":83,"line":5771},75,[81,5773,5774],{"class":98},"    `UPDATE tenants \n",[81,5776,5778],{"class":83,"line":5777},76,[81,5779,5780],{"class":98},"     SET subscription_status = $1, stripe_price_id = $2, subscription_period_end = $3\n",[81,5782,5784,5787],{"class":83,"line":5783},77,[81,5785,5786],{"class":98},"     WHERE id = $4`",[81,5788,2612],{"class":91},[81,5790,5792],{"class":83,"line":5791},78,[81,5793,5794],{"class":91},"    [status, priceId, currentPeriodEnd, tenantId]\n",[81,5796,5798],{"class":83,"line":5797},79,[81,5799,5800],{"class":91},"  );\n",[81,5802,5804],{"class":83,"line":5803},80,[81,5805,109],{"emptyLinePlaceholder":108},[81,5807,5809,5812,5814,5816,5819,5822,5825,5827,5829],{"class":83,"line":5808},81,[81,5810,5811],{"class":91},"  console.",[81,5813,5480],{"class":118},[81,5815,271],{"class":91},[81,5817,5818],{"class":98},"`[Billing Sync] Updated tenant ${",[81,5820,5821],{"class":91},"tenantId",[81,5823,5824],{"class":98},"} to status: ${",[81,5826,5128],{"class":91},[81,5828,512],{"class":98},[81,5830,342],{"class":91},[81,5832,5834],{"class":83,"line":5833},82,[81,5835,207],{"class":91},[81,5837,5839],{"class":83,"line":5838},83,[81,5840,109],{"emptyLinePlaceholder":108},[81,5842,5844,5846,5848,5850,5852,5854,5856,5858,5860,5862],{"class":83,"line":5843},84,[81,5845,5602],{"class":87},[81,5847,265],{"class":87},[81,5849,5402],{"class":118},[81,5851,271],{"class":91},[81,5853,5611],{"class":128},[81,5855,132],{"class":87},[81,5857,4080],{"class":118},[81,5859,62],{"class":91},[81,5861,5342],{"class":118},[81,5863,5223],{"class":91},[81,5865,5867,5869,5871,5873],{"class":83,"line":5866},85,[81,5868,303],{"class":87},[81,5870,5629],{"class":135},[81,5872,309],{"class":87},[81,5874,5634],{"class":91},[81,5876,5878,5880,5882,5884,5887,5890],{"class":83,"line":5877},86,[81,5879,354],{"class":87},[81,5881,357],{"class":91},[81,5883,360],{"class":87},[81,5885,5886],{"class":91},"tenantId) ",[81,5888,5889],{"class":87},"return",[81,5891,102],{"class":91},[81,5893,5895],{"class":83,"line":5894},87,[81,5896,109],{"emptyLinePlaceholder":108},[81,5898,5900,5902,5904,5906],{"class":83,"line":5899},88,[81,5901,5760],{"class":87},[81,5903,5763],{"class":91},[81,5905,5766],{"class":118},[81,5907,5019],{"class":91},[81,5909,5911],{"class":83,"line":5910},89,[81,5912,5774],{"class":98},[81,5914,5916],{"class":83,"line":5915},90,[81,5917,5918],{"class":98},"     SET subscription_status = 'canceled', stripe_price_id = NULL\n",[81,5920,5922,5925],{"class":83,"line":5921},91,[81,5923,5924],{"class":98},"     WHERE id = $1`",[81,5926,2612],{"class":91},[81,5928,5930],{"class":83,"line":5929},92,[81,5931,5932],{"class":91},"    [tenantId]\n",[81,5934,5936],{"class":83,"line":5935},93,[81,5937,5800],{"class":91},[81,5939,5941],{"class":83,"line":5940},94,[81,5942,109],{"emptyLinePlaceholder":108},[81,5944,5946,5948,5950,5952,5955,5957,5959],{"class":83,"line":5945},95,[81,5947,5811],{"class":91},[81,5949,5480],{"class":118},[81,5951,271],{"class":91},[81,5953,5954],{"class":98},"`[Billing Sync] Revoked subscription access for tenant ${",[81,5956,5821],{"class":91},[81,5958,512],{"class":98},[81,5960,342],{"class":91},[81,5962,5964],{"class":83,"line":5963},96,[81,5965,207],{"class":91},[81,5967,5969],{"class":83,"line":5968},97,[81,5970,109],{"emptyLinePlaceholder":108},[81,5972,5974,5976,5978,5980,5982,5985,5987,5989,5991,5993],{"class":83,"line":5973},98,[81,5975,5602],{"class":87},[81,5977,265],{"class":87},[81,5979,5452],{"class":118},[81,5981,271],{"class":91},[81,5983,5984],{"class":128},"invoice",[81,5986,132],{"class":87},[81,5988,4080],{"class":118},[81,5990,62],{"class":91},[81,5992,5443],{"class":118},[81,5994,5223],{"class":91},[81,5996,5998,6000,6003,6005,6008,6010,6012],{"class":83,"line":5997},99,[81,5999,303],{"class":87},[81,6001,6002],{"class":135}," customerId",[81,6004,309],{"class":87},[81,6006,6007],{"class":91}," invoice.customer ",[81,6009,1043],{"class":87},[81,6011,149],{"class":135},[81,6013,102],{"class":91},[81,6015,6017],{"class":83,"line":6016},100,[81,6018,6019],{"class":395},"  \u002F\u002F Fetch user details from customerId and trigger alert emails\n",[81,6021,6023,6025,6028,6030,6033,6036,6038],{"class":83,"line":6022},101,[81,6024,5811],{"class":91},[81,6026,6027],{"class":118},"warn",[81,6029,271],{"class":91},[81,6031,6032],{"class":98},"`[Billing Alert] Payment failed for customer ${",[81,6034,6035],{"class":91},"customerId",[81,6037,512],{"class":98},[81,6039,342],{"class":91},[81,6041,6043],{"class":83,"line":6042},102,[81,6044,207],{"class":91},[26,6046,6048],{"id":6047},"_3-best-practices-for-b2b-saas-billing-architectures","3. Best Practices for B2B SaaS Billing Architectures",[1233,6050,6051,6057,6070],{},[1236,6052,6053,6056],{},[15,6054,6055],{},"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,6058,6059,6062,6063,6065,6066,6069],{},[15,6060,6061],{},"Pass IDs in Metadata",": When launching a Checkout Session, pass your database's ",[42,6064,5821],{}," or ",[42,6067,6068],{},"userId"," in the session's metadata. Stripe returns this metadata in webhook events, allowing you to associate payments with database records.",[1236,6071,6072,6075],{},[15,6073,6074],{},"Idempotency",": Webhook deliveries can occasionally double-fire. Implement checks to ensure your database updates do not trigger duplicate processing logs or invoice generations.",[11,6077,6078,6079,6081,6082,62],{},"For example, in ",[1274,6080,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,6083,6085],{"href":6084},"\u002Fblog\u002Ftelegram-web-apps-sells","Telegram Web Apps SaaS Development",[26,6087,1294],{"id":1293},[1296,6089,6090,6098,6106],{},[1236,6091,6092,6097],{},[1274,6093,6096],{"href":6094,"rel":6095},"https:\u002F\u002Fdocs.stripe.com\u002Fwebhooks",[1304],"Stripe Webhooks"," — delivery, signatures, and retry behavior",[1236,6099,6100,6105],{},[1274,6101,6104],{"href":6102,"rel":6103},"https:\u002F\u002Fdocs.stripe.com\u002Fbilling\u002Fsubscriptions\u002Foverview",[1304],"Stripe: subscription lifecycle"," — every status from the diagram above",[1236,6107,6108,6113],{},[1274,6109,6112],{"href":6110,"rel":6111},"https:\u002F\u002Fdocs.stripe.com\u002Fstripe-cli",[1304],"Stripe CLI"," — local webhook testing",[11,6115,6116,6117,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,6118,6119],{"href":3796},"Building a B2B SaaS MVP in 30 Days",[11,6121,6122,6123,6125,6126,6128],{},"If you are planning to build or refactor a billing integration in your SaaS application, learn more about my ",[1274,6124,3041],{"href":3401}," or schedule a ",[1274,6127,2922],{"href":1328}," to design a seamless payment pipeline.",[1332,6130,6131],{},"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":6133},[6134,6135,6136,6137],{"id":4828,"depth":105,"text":4829},{"id":4871,"depth":105,"text":4872},{"id":6047,"depth":105,"text":6048},{"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.",[6141,6144,6147],{"q":6142,"a":6143},"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":6145,"a":6146},"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":6148,"a":6149},"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":4814,"description":6139},"blog\u002Fen\u002Fsaas-stripe-billing-integration",[1368,6156,1369,6157,6158],"Payments","API","Stripe","dHjr7aCyMCu-QM5qwtolluk5ulHJI_bgAT38LbmVtu0",{"id":6161,"title":6162,"body":6163,"date":6848,"description":6849,"extension":1348,"faq":6850,"image":6860,"lang":1360,"meta":6861,"navigation":108,"path":6862,"published":108,"readTime":215,"seo":6863,"stem":6864,"tags":6865,"updated":1346,"__hash__":6869},"blog\u002Fblog\u002Fen\u002Fsaas-multitenancy-databases.md","SaaS Database Architecture: Single-Tenant vs. Multi-Tenant Patterns",{"type":8,"value":6164,"toc":6837},[6165,6168,6171,6175,6181,6185,6188,6327,6331,6338,6345,6349,6356,6461,6465,6471,6491,6495,6498,6756,6760,6786,6792,6794,6820,6826,6834],[11,6166,6167],{},"Choosing the database architecture for your B2B SaaS is one of the most critical decisions you will make. It directly impacts your infrastructure costs, development velocity, database migration complexity, and compliance with data security standards (such as GDPR, HIPAA, or SOC2).",[11,6169,6170],{},"A mistake here is incredibly expensive to rectify later. In this technical guide, we will analyze the three primary multi-tenant database patterns, map their trade-offs in detail, and write a complete, secure implementation using PostgreSQL Row-Level Security (RLS).",[26,6172,6174],{"id":6173},"the-three-multi-tenant-paradigms","The Three Multi-Tenant Paradigms",[34,6176,6179],{"className":6177,"code":6178,"language":39,"meta":40},[37],"+-----------------------+   +-----------------------+   +-----------------------+\n|  Pattern A: Separate  |   |  Pattern B: Separate  |   |   Pattern C: Shared   |\n|       Databases       |   |        Schemas        |   |    Database & Tables  |\n+-----------------------+   +-----------------------+   +-----------------------+\n|  [DB 1]      [DB 2]   |   |  [Database]           |   |  [Database]           |\n|  Tenant A    Tenant B |   |   ├── Schema Tenant A |   |   └── [Table]         |\n|                       |   |   └── Schema Tenant B |   |        ├── Row Tenant A|\n| (Max Isolation\u002FCost)  |   | (Medium Isolation)    |   |        └── Row Tenant B|\n+-----------------------+   +-----------------------+   +-----------------------+\n",[42,6180,6178],{"__ignoreMap":40},[26,6182,6184],{"id":6183},"_1-deep-dive-comparison","1. Deep Dive Comparison",[11,6186,6187],{},"To make an informed decision, let's contrast the three patterns across operational metrics:",[2422,6189,6190,6206],{},[2425,6191,6192],{},[2428,6193,6194,6197,6200,6203],{},[2431,6195,6196],{"align":2433},"Metric",[2431,6198,6199],{"align":2433},"Pattern A: Separate DBs",[2431,6201,6202],{"align":2433},"Pattern B: Separate Schemas",[2431,6204,6205],{"align":2433},"Pattern C: Shared Table (RLS)",[2442,6207,6208,6233,6256,6280,6304],{},[2428,6209,6210,6215,6221,6227],{},[2447,6211,6212],{"align":2433},[15,6213,6214],{},"Data Isolation",[2447,6216,6217,6220],{"align":2433},[15,6218,6219],{},"Highest",": Zero risk of cross-tenant leakage.",[2447,6222,6223,6226],{"align":2433},[15,6224,6225],{},"High",": Logic-level namespace isolation.",[2447,6228,6229,6232],{"align":2433},[15,6230,6231],{},"Medium",": Relies on SQL query filters or database policies.",[2428,6234,6235,6240,6245,6250],{},[2447,6236,6237],{"align":2433},[15,6238,6239],{},"Infrastructure Cost",[2447,6241,6242,6244],{"align":2433},[15,6243,6219],{},": Resource overhead for running multiple DB servers or pools.",[2447,6246,6247,6249],{"align":2433},[15,6248,6231],{},": Shared hardware, catalog memory limits.",[2447,6251,6252,6255],{"align":2433},[15,6253,6254],{},"Lowest",": Maximum utilization of database resources.",[2428,6257,6258,6263,6269,6274],{},[2447,6259,6260],{"align":2433},[15,6261,6262],{},"Migrations Complexity",[2447,6264,6265,6268],{"align":2433},[15,6266,6267],{},"Very High",": Need to run migration scripts on hundreds of DBs in sequence.",[2447,6270,6271,6273],{"align":2433},[15,6272,6225],{},": Iterating updates through hundreds of schemas.",[2447,6275,6276,6279],{"align":2433},[15,6277,6278],{},"Simple",": Single standard migration run.",[2428,6281,6282,6287,6293,6298],{},[2447,6283,6284],{"align":2433},[15,6285,6286],{},"Connection Pooling",[2447,6288,6289,6292],{"align":2433},[15,6290,6291],{},"Hard",": Separate connection pool required per database.",[2447,6294,6295,6297],{"align":2433},[15,6296,6231],{},": Can use one pool but requires schema-switching commands.",[2447,6299,6300,6303],{"align":2433},[15,6301,6302],{},"Easiest",": Single connection pool shared by all tenants.",[2428,6305,6306,6311,6317,6322],{},[2447,6307,6308],{"align":2433},[15,6309,6310],{},"Backup & Restore",[2447,6312,6313,6316],{"align":2433},[15,6314,6315],{},"Perfect",": Can easily back up or restore a single customer.",[2447,6318,6319,6321],{"align":2433},[15,6320,6231],{},": Requires selective pg_dump\u002Fpg_restore of schemas.",[2447,6323,6324,6326],{"align":2433},[15,6325,6291],{},": Restoring one client requires filtering logs or data exports.",[26,6328,6330],{"id":6329},"_2-implementing-row-level-security-rls-in-postgresql","2. Implementing Row-Level Security (RLS) in PostgreSQL",[11,6332,6333,6334,6337],{},"If you choose ",[15,6335,6336],{},"Pattern C"," (Shared Database & Tables) due to its cost efficiency and ease of maintenance, you must guarantee that users cannot accidentally or maliciously access another customer's data.",[11,6339,6340,6341,6344],{},"PostgreSQL ",[15,6342,6343],{},"Row-Level Security (RLS)"," handles this by applying filter policies directly inside the SQL engine.",[864,6346,6348],{"id":6347},"step-1-create-tables-and-enable-rls","Step 1: Create Tables and Enable RLS",[11,6350,6351,6352,6355],{},"We will design a simple CRM table for storing leads. We explicitly enable RLS using the ",[42,6353,6354],{},"ALTER TABLE"," statement.",[34,6357,6361],{"className":6358,"code":6359,"language":6360,"meta":40,"style":40},"language-sql shiki shiki-themes github-dark","-- Create tenants table\nCREATE TABLE tenants (\n  id SERIAL PRIMARY KEY,\n  company_name VARCHAR(255) NOT NULL,\n  created_at TIMESTAMP DEFAULT NOW()\n);\n\n-- Create projects table with tenant mapping\nCREATE TABLE projects (\n  id SERIAL PRIMARY KEY,\n  tenant_id INT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,\n  name VARCHAR(255) NOT NULL,\n  status VARCHAR(50) DEFAULT 'draft',\n  created_at TIMESTAMP DEFAULT NOW()\n);\n\n-- Index the tenant_id column (Crucial for query performance across multiple tenants)\nCREATE INDEX idx_projects_tenant ON projects(tenant_id);\n\n-- Enable RLS for the projects table\nALTER TABLE projects ENABLE ROW LEVEL SECURITY;\n","sql",[42,6362,6363,6368,6373,6378,6383,6388,6392,6396,6401,6406,6410,6415,6420,6425,6429,6433,6437,6442,6447,6451,6456],{"__ignoreMap":40},[81,6364,6365],{"class":83,"line":84},[81,6366,6367],{},"-- Create tenants table\n",[81,6369,6370],{"class":83,"line":105},[81,6371,6372],{},"CREATE TABLE tenants (\n",[81,6374,6375],{"class":83,"line":112},[81,6376,6377],{},"  id SERIAL PRIMARY KEY,\n",[81,6379,6380],{"class":83,"line":125},[81,6381,6382],{},"  company_name VARCHAR(255) NOT NULL,\n",[81,6384,6385],{"class":83,"line":141},[81,6386,6387],{},"  created_at TIMESTAMP DEFAULT NOW()\n",[81,6389,6390],{"class":83,"line":154},[81,6391,342],{},[81,6393,6394],{"class":83,"line":167},[81,6395,109],{"emptyLinePlaceholder":108},[81,6397,6398],{"class":83,"line":179},[81,6399,6400],{},"-- Create projects table with tenant mapping\n",[81,6402,6403],{"class":83,"line":191},[81,6404,6405],{},"CREATE TABLE projects (\n",[81,6407,6408],{"class":83,"line":204},[81,6409,6377],{},[81,6411,6412],{"class":83,"line":210},[81,6413,6414],{},"  tenant_id INT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,\n",[81,6416,6417],{"class":83,"line":215},[81,6418,6419],{},"  name VARCHAR(255) NOT NULL,\n",[81,6421,6422],{"class":83,"line":225},[81,6423,6424],{},"  status VARCHAR(50) DEFAULT 'draft',\n",[81,6426,6427],{"class":83,"line":237},[81,6428,6387],{},[81,6430,6431],{"class":83,"line":249},[81,6432,342],{},[81,6434,6435],{"class":83,"line":254},[81,6436,109],{"emptyLinePlaceholder":108},[81,6438,6439],{"class":83,"line":259},[81,6440,6441],{},"-- Index the tenant_id column (Crucial for query performance across multiple tenants)\n",[81,6443,6444],{"class":83,"line":300},[81,6445,6446],{},"CREATE INDEX idx_projects_tenant ON projects(tenant_id);\n",[81,6448,6449],{"class":83,"line":321},[81,6450,109],{"emptyLinePlaceholder":108},[81,6452,6453],{"class":83,"line":345},[81,6454,6455],{},"-- Enable RLS for the projects table\n",[81,6457,6458],{"class":83,"line":351},[81,6459,6460],{},"ALTER TABLE projects ENABLE ROW LEVEL SECURITY;\n",[864,6462,6464],{"id":6463},"step-2-define-the-security-policy","Step 2: Define the Security Policy",[11,6466,6467,6468,62],{},"We will configure PostgreSQL to restrict access based on a session configuration variable called ",[42,6469,6470],{},"app.current_tenant_id",[34,6472,6474],{"className":6358,"code":6473,"language":6360,"meta":40,"style":40},"CREATE POLICY tenant_isolation_policy ON projects\n  AS ASYMMETRIC\n  USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::integer);\n",[42,6475,6476,6481,6486],{"__ignoreMap":40},[81,6477,6478],{"class":83,"line":84},[81,6479,6480],{},"CREATE POLICY tenant_isolation_policy ON projects\n",[81,6482,6483],{"class":83,"line":105},[81,6484,6485],{},"  AS ASYMMETRIC\n",[81,6487,6488],{"class":83,"line":112},[81,6489,6490],{},"  USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::integer);\n",[864,6492,6494],{"id":6493},"step-3-accessing-data-from-the-application-layer","Step 3: Accessing Data from the Application Layer",[11,6496,6497],{},"When your backend (e.g., Node.js\u002FFastify or Nuxt server routes) connects to PostgreSQL, it runs queries inside a transaction, setting the session context variable first.",[34,6499,6501],{"className":75,"code":6500,"language":77,"meta":40,"style":40},"import { Client } from 'pg';\n\nasync function fetchTenantProjects(tenantId: number): Promise\u003Cany[]> {\n  const client = new Client({ connectionString: process.env.DATABASE_URL });\n  await client.connect();\n\n  try {\n    \u002F\u002F Start transaction\n    await client.query('BEGIN');\n\n    \u002F\u002F Set the tenant ID context variable for the duration of this transaction\n    await client.query(`SET LOCAL app.current_tenant_id = $1`, [tenantId]);\n\n    \u002F\u002F Even if we query SELECT * without a WHERE clause, RLS filters the records\n    const result = await client.query('SELECT * FROM projects');\n\n    await client.query('COMMIT');\n    return result.rows;\n  } catch (error) {\n    await client.query('ROLLBACK');\n    throw error;\n  } finally {\n    await client.end();\n  }\n}\n",[42,6502,6503,6517,6521,6551,6573,6585,6589,6595,6600,6616,6620,6625,6641,6645,6650,6672,6676,6691,6698,6706,6721,6728,6737,6748,6752],{"__ignoreMap":40},[81,6504,6505,6507,6510,6512,6515],{"class":83,"line":84},[81,6506,88],{"class":87},[81,6508,6509],{"class":91}," { Client } ",[81,6511,95],{"class":87},[81,6513,6514],{"class":98}," 'pg'",[81,6516,102],{"class":91},[81,6518,6519],{"class":83,"line":105},[81,6520,109],{"emptyLinePlaceholder":108},[81,6522,6523,6525,6527,6530,6532,6534,6536,6538,6540,6542,6544,6546,6548],{"class":83,"line":112},[81,6524,5602],{"class":87},[81,6526,265],{"class":87},[81,6528,6529],{"class":118}," fetchTenantProjects",[81,6531,271],{"class":91},[81,6533,5821],{"class":128},[81,6535,132],{"class":87},[81,6537,136],{"class":135},[81,6539,291],{"class":91},[81,6541,132],{"class":87},[81,6543,4190],{"class":118},[81,6545,1000],{"class":91},[81,6547,1003],{"class":135},[81,6549,6550],{"class":91},"[]> {\n",[81,6552,6553,6555,6558,6560,6562,6565,6568,6571],{"class":83,"line":125},[81,6554,303],{"class":87},[81,6556,6557],{"class":135}," client",[81,6559,309],{"class":87},[81,6561,312],{"class":87},[81,6563,6564],{"class":118}," Client",[81,6566,6567],{"class":91},"({ connectionString: process.env.",[81,6569,6570],{"class":135},"DATABASE_URL",[81,6572,5520],{"class":91},[81,6574,6575,6577,6580,6583],{"class":83,"line":141},[81,6576,5760],{"class":87},[81,6578,6579],{"class":91}," client.",[81,6581,6582],{"class":118},"connect",[81,6584,449],{"class":91},[81,6586,6587],{"class":83,"line":154},[81,6588,109],{"emptyLinePlaceholder":108},[81,6590,6591,6593],{"class":83,"line":167},[81,6592,747],{"class":87},[81,6594,122],{"class":91},[81,6596,6597],{"class":83,"line":179},[81,6598,6599],{"class":395},"    \u002F\u002F Start transaction\n",[81,6601,6602,6605,6607,6609,6611,6614],{"class":83,"line":191},[81,6603,6604],{"class":87},"    await",[81,6606,6579],{"class":91},[81,6608,5766],{"class":118},[81,6610,271],{"class":91},[81,6612,6613],{"class":98},"'BEGIN'",[81,6615,342],{"class":91},[81,6617,6618],{"class":83,"line":204},[81,6619,109],{"emptyLinePlaceholder":108},[81,6621,6622],{"class":83,"line":210},[81,6623,6624],{"class":395},"    \u002F\u002F Set the tenant ID context variable for the duration of this transaction\n",[81,6626,6627,6629,6631,6633,6635,6638],{"class":83,"line":215},[81,6628,6604],{"class":87},[81,6630,6579],{"class":91},[81,6632,5766],{"class":118},[81,6634,271],{"class":91},[81,6636,6637],{"class":98},"`SET LOCAL app.current_tenant_id = $1`",[81,6639,6640],{"class":91},", [tenantId]);\n",[81,6642,6643],{"class":83,"line":225},[81,6644,109],{"emptyLinePlaceholder":108},[81,6646,6647],{"class":83,"line":237},[81,6648,6649],{"class":395},"    \u002F\u002F Even if we query SELECT * without a WHERE clause, RLS filters the records\n",[81,6651,6652,6654,6657,6659,6661,6663,6665,6667,6670],{"class":83,"line":249},[81,6653,755],{"class":87},[81,6655,6656],{"class":135}," result",[81,6658,309],{"class":87},[81,6660,4210],{"class":87},[81,6662,6579],{"class":91},[81,6664,5766],{"class":118},[81,6666,271],{"class":91},[81,6668,6669],{"class":98},"'SELECT * FROM projects'",[81,6671,342],{"class":91},[81,6673,6674],{"class":83,"line":254},[81,6675,109],{"emptyLinePlaceholder":108},[81,6677,6678,6680,6682,6684,6686,6689],{"class":83,"line":259},[81,6679,6604],{"class":87},[81,6681,6579],{"class":91},[81,6683,5766],{"class":118},[81,6685,271],{"class":91},[81,6687,6688],{"class":98},"'COMMIT'",[81,6690,342],{"class":91},[81,6692,6693,6695],{"class":83,"line":300},[81,6694,369],{"class":87},[81,6696,6697],{"class":91}," result.rows;\n",[81,6699,6700,6702,6704],{"class":83,"line":321},[81,6701,828],{"class":91},[81,6703,831],{"class":87},[81,6705,834],{"class":91},[81,6707,6708,6710,6712,6714,6716,6719],{"class":83,"line":345},[81,6709,6604],{"class":87},[81,6711,6579],{"class":91},[81,6713,5766],{"class":118},[81,6715,271],{"class":91},[81,6717,6718],{"class":98},"'ROLLBACK'",[81,6720,342],{"class":91},[81,6722,6723,6725],{"class":83,"line":351},[81,6724,4363],{"class":87},[81,6726,6727],{"class":91}," error;\n",[81,6729,6730,6732,6735],{"class":83,"line":366},[81,6731,828],{"class":91},[81,6733,6734],{"class":87},"finally",[81,6736,122],{"class":91},[81,6738,6739,6741,6743,6746],{"class":83,"line":381},[81,6740,6604],{"class":87},[81,6742,6579],{"class":91},[81,6744,6745],{"class":118},"end",[81,6747,449],{"class":91},[81,6749,6750],{"class":83,"line":387},[81,6751,384],{"class":91},[81,6753,6754],{"class":83,"line":392},[81,6755,207],{"class":91},[26,6757,6759],{"id":6758},"_3-best-practices-for-scaling-multi-tenant-databases","3. Best Practices for Scaling Multi-Tenant Databases",[1233,6761,6762,6771,6777],{},[1236,6763,6764,6770],{},[15,6765,6766,6767],{},"Always Index ",[42,6768,6769],{},"tenant_id",": If you don't index the tenant column, every query will perform a sequential table scan, destroying database performance as the table grows.",[1236,6772,6773,6776],{},[15,6774,6775],{},"Verify policies in automated tests",": Write integration tests that attempt to fetch Tenant B's data using Tenant A's connection context, verifying that the database throws access errors or returns empty results.",[1236,6778,6779,6782,6783,6785],{},[15,6780,6781],{},"Prepare for tenant migration",": Enterprise customers may eventually ask to move to a dedicated database (Pattern A) for regulatory reasons. Design your schemas so you can easily extract records matching a specific ",[42,6784,6769],{}," and load them into a standalone instance.",[11,6787,6788,6789,6791],{},"For example, in the ",[1274,6790,2872],{"href":2871},", we successfully implemented Row-Level Isolation (Pattern C) using indexed tenant schemas, keeping monthly database fees extremely low while serving thousands of registered learners.",[26,6793,1294],{"id":1293},[1296,6795,6796,6804,6812],{},[1236,6797,6798,6803],{},[1274,6799,6802],{"href":6800,"rel":6801},"https:\u002F\u002Fwww.postgresql.org\u002Fdocs\u002Fcurrent\u002Fddl-rowsecurity.html",[1304],"PostgreSQL: Row Security Policies"," — official RLS documentation",[1236,6805,6806,6811],{},[1274,6807,6810],{"href":6808,"rel":6809},"https:\u002F\u002Fwww.postgresql.org\u002Fdocs\u002Fcurrent\u002Fddl-schemas.html",[1304],"PostgreSQL: Schemas"," — logical schemas for Pattern B",[1236,6813,6814,6819],{},[1274,6815,6818],{"href":6816,"rel":6817},"https:\u002F\u002Fgdpr.eu\u002F",[1304],"GDPR.eu"," — isolation and data-portability requirements for personal data",[11,6821,6822,6823,62],{},"Choosing the right multi-tenant paradigm involves balancing your startup's budget, resources, and regulatory requirements. RLS offers an elegant and secure way to build a cost-effective, scalable B2B SaaS without compromising data safety. To see how this database setup ties into a full development lifecycle, explore my guide on ",[1274,6824,6825],{"href":3796},"Building B2B SaaS MVPs in 30 Days",[11,6827,6828,6829,2919,6831,6833],{},"If you are designing the data layer for a multi-tenant application and want an expert architecture review to ensure it handles growth safely, explore my ",[1274,6830,3041],{"href":3401},[1274,6832,1329],{"href":1328}," to get a production plan.",[1332,6835,6836],{},"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);}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}",{"title":40,"searchDepth":105,"depth":105,"links":6838},[6839,6840,6841,6846,6847],{"id":6173,"depth":105,"text":6174},{"id":6183,"depth":105,"text":6184},{"id":6329,"depth":105,"text":6330,"children":6842},[6843,6844,6845],{"id":6347,"depth":112,"text":6348},{"id":6463,"depth":112,"text":6464},{"id":6493,"depth":112,"text":6494},{"id":6758,"depth":105,"text":6759},{"id":1293,"depth":105,"text":1294},"2026-06-11","A comprehensive deep dive into designing secure, scalable multi-tenant database systems for B2B SaaS. We compare shared tables, PostgreSQL schemas, and independent databases.",[6851,6854,6857],{"q":6852,"a":6853},"Which multi-tenancy pattern should a SaaS start with?","For most B2B startups — a shared database with Row-Level Security (Pattern C): minimal server costs, simple migrations, and sufficient isolation when policies are written correctly. Dedicated databases are justified only by strict regulatory requirements.",{"q":6855,"a":6856},"How safe is isolation via PostgreSQL RLS?","With RLS policies enabled, the database itself filters rows by tenant_id at the engine level — even a buggy application query won't return another tenant's data. The mandatory companion: integration tests that try to read one tenant's data through another tenant's session.",{"q":6858,"a":6859},"What if a large customer demands a dedicated database?","Design for export upfront: if every table is linked through tenant_id, one customer's data can be extracted with standard filtering and moved to a standalone instance without rewriting the application.","\u002Fimages\u002Fblog\u002Fblog_saas_multitenancy.jpg",{},"\u002Fblog\u002Fen\u002Fsaas-multitenancy-databases",{"title":6162,"description":6849},"blog\u002Fen\u002Fsaas-multitenancy-databases",[1368,6866,4505,6867,6868],"Databases","PostgreSQL","SQL","mYFBuJiFCFdIzU4HHQhXKU34ogE4rNG33yYrIYqzgSk",{"id":6871,"title":6872,"body":6873,"date":7533,"description":7534,"extension":1348,"faq":7535,"image":7545,"lang":1360,"meta":7546,"navigation":108,"path":7547,"published":108,"readTime":215,"seo":7548,"stem":7549,"tags":7550,"updated":1346,"__hash__":7554},"blog\u002Fblog\u002Fen\u002Fcode-audit-technical-debt.md","Codebase Audit Checklist: How to Assess Technical Debt Before Scaling",{"type":8,"value":6874,"toc":7523},[6875,6878,6881,6887,6890,6894,6897,6984,6988,6992,6995,7000,7164,7168,7171,7271,7275,7278,7304,7423,7427,7430,7456,7460,7463,7483,7485,7508,7513,7520],[11,6876,6877],{},"Taking over a legacy codebase, acquiring an existing SaaS startup, or preparing a MVP for a major scaling phase is a critical turning point. In these scenarios, technical debt is your biggest invisible enemy.",[11,6879,6880],{},"Code that appears to function perfectly on a developer's machine can harbor security vulnerabilities, lack proper database indices, or contain architectural flaws that make adding new features take weeks instead of days.",[11,6882,6883,6884,62],{},"To scale safely and prevent expensive rewrite cycles, you must perform a thorough ",[15,6885,6886],{},"codebase audit",[11,6888,6889],{},"In this senior developer guide, we present a technical playbook for auditing Node.js\u002FTypeScript applications, identifying DB bottlenecks (like the N+1 query problem), checking security controls, and organizing a refactoring plan.",[26,6891,6893],{"id":6892},"_1-automated-meticulous-metrics-check","1. Automated Meticulous Metrics Check",[11,6895,6896],{},"Before looking at custom business logic, gather quantitative metrics using specialized analysis tooling. This sets a baseline for your engineering teams:",[2422,6898,6899,6915],{},[2425,6900,6901],{},[2428,6902,6903,6906,6909,6912],{},[2431,6904,6905],{"align":2433},"Audit Layer",[2431,6907,6908],{"align":2433},"Tool Recommendation",[2431,6910,6911],{"align":2433},"Standard Metric",[2431,6913,6914],{"align":2433},"Impact of Failure",[2442,6916,6917,6936,6952,6968],{},[2428,6918,6919,6924,6930,6933],{},[2447,6920,6921],{"align":2433},[15,6922,6923],{},"Dependency Security",[2447,6925,6926,6929],{"align":2433},[42,6927,6928],{},"npm audit",", Snyk, Dependabot",[2447,6931,6932],{"align":2433},"0 Critical\u002FHigh issues",[2447,6934,6935],{"align":2433},"Remote Code Execution, data injection vulnerabilities.",[2428,6937,6938,6943,6946,6949],{},[2447,6939,6940],{"align":2433},[15,6941,6942],{},"Code Style & Linting",[2447,6944,6945],{"align":2433},"Biome, ESLint, Prettier",[2447,6947,6948],{"align":2433},"0 warnings\u002Ferrors",[2447,6950,6951],{"align":2433},"Visual inconsistency, syntax pitfalls, merge conflicts.",[2428,6953,6954,6959,6962,6965],{},[2447,6955,6956],{"align":2433},[15,6957,6958],{},"Test Coverage",[2447,6960,6961],{"align":2433},"Vitest, Jest, Playwright",[2447,6963,6964],{"align":2433},"> 70% unit coverage",[2447,6966,6967],{"align":2433},"High regression risk when refactoring core modules.",[2428,6969,6970,6975,6978,6981],{},[2447,6971,6972],{"align":2433},[15,6973,6974],{},"Complexity Analysis",[2447,6976,6977],{"align":2433},"SonarQube, Plato",[2447,6979,6980],{"align":2433},"Low Cyclomatic Complexity",[2447,6982,6983],{"align":2433},"Hard-to-read functions with deep nested conditional branches.",[26,6985,6987],{"id":6986},"_2-technical-audit-crucial-checkpoints","2. Technical Audit: Crucial Checkpoints",[864,6989,6991],{"id":6990},"a-the-database-layer-solving-the-n1-query-problem","A. The Database Layer: Solving the N+1 Query Problem",[11,6993,6994],{},"A common scaling bottleneck is the N+1 database query pattern. This occurs when your backend queries a database to retrieve a list of records, and then runs separate queries for each retrieved record to fetch associated relationships.",[6996,6997,6999],"h4",{"id":6998},"the-bad-pattern-n1-query-in-express-api","The Bad Pattern (N+1 Query in Express API)",[34,7001,7003],{"className":75,"code":7002,"language":77,"meta":40,"style":40},"\u002F\u002F GET \u002Fapi\u002Fprojects - Pulls N projects, then makes N database calls to get users\napp.get('\u002Fapi\u002Fprojects', async (req, res) => {\n  const projects = await db.query('SELECT * FROM projects'); \u002F\u002F 1 Query\n  const enrichedProjects = [];\n\n  for (const project of projects) {\n    \u002F\u002F RUNS N TIMES! If you have 1000 projects, this runs 1000 queries.\n    const user = await db.query('SELECT * FROM users WHERE id = $1', [project.userId]); \n    enrichedProjects.push({ ...project, user });\n  }\n\n  res.json(enrichedProjects);\n});\n",[42,7004,7005,7010,7039,7064,7076,7080,7098,7103,7125,7142,7146,7150,7160],{"__ignoreMap":40},[81,7006,7007],{"class":83,"line":84},[81,7008,7009],{"class":395},"\u002F\u002F GET \u002Fapi\u002Fprojects - Pulls N projects, then makes N database calls to get users\n",[81,7011,7012,7014,7016,7018,7021,7023,7025,7027,7029,7031,7033,7035,7037],{"class":83,"line":105},[81,7013,5013],{"class":91},[81,7015,334],{"class":118},[81,7017,271],{"class":91},[81,7019,7020],{"class":98},"'\u002Fapi\u002Fprojects'",[81,7022,281],{"class":91},[81,7024,5602],{"class":87},[81,7026,357],{"class":91},[81,7028,5053],{"class":128},[81,7030,281],{"class":91},[81,7032,5067],{"class":128},[81,7034,5079],{"class":91},[81,7036,1026],{"class":87},[81,7038,122],{"class":91},[81,7040,7041,7043,7046,7048,7050,7052,7054,7056,7058,7061],{"class":83,"line":112},[81,7042,303],{"class":87},[81,7044,7045],{"class":135}," projects",[81,7047,309],{"class":87},[81,7049,4210],{"class":87},[81,7051,5763],{"class":91},[81,7053,5766],{"class":118},[81,7055,271],{"class":91},[81,7057,6669],{"class":98},[81,7059,7060],{"class":91},"); ",[81,7062,7063],{"class":395},"\u002F\u002F 1 Query\n",[81,7065,7066,7068,7071,7073],{"class":83,"line":125},[81,7067,303],{"class":87},[81,7069,7070],{"class":135}," enrichedProjects",[81,7072,309],{"class":87},[81,7074,7075],{"class":91}," [];\n",[81,7077,7078],{"class":83,"line":141},[81,7079,109],{"emptyLinePlaceholder":108},[81,7081,7082,7085,7087,7089,7092,7095],{"class":83,"line":154},[81,7083,7084],{"class":87},"  for",[81,7086,357],{"class":91},[81,7088,4070],{"class":87},[81,7090,7091],{"class":135}," project",[81,7093,7094],{"class":87}," of",[81,7096,7097],{"class":91}," projects) {\n",[81,7099,7100],{"class":83,"line":167},[81,7101,7102],{"class":395},"    \u002F\u002F RUNS N TIMES! If you have 1000 projects, this runs 1000 queries.\n",[81,7104,7105,7107,7109,7111,7113,7115,7117,7119,7122],{"class":83,"line":179},[81,7106,755],{"class":87},[81,7108,779],{"class":135},[81,7110,309],{"class":87},[81,7112,4210],{"class":87},[81,7114,5763],{"class":91},[81,7116,5766],{"class":118},[81,7118,271],{"class":91},[81,7120,7121],{"class":98},"'SELECT * FROM users WHERE id = $1'",[81,7123,7124],{"class":91},", [project.userId]); \n",[81,7126,7127,7130,7133,7136,7139],{"class":83,"line":191},[81,7128,7129],{"class":91},"    enrichedProjects.",[81,7131,7132],{"class":118},"push",[81,7134,7135],{"class":91},"({ ",[81,7137,7138],{"class":87},"...",[81,7140,7141],{"class":91},"project, user });\n",[81,7143,7144],{"class":83,"line":204},[81,7145,384],{"class":91},[81,7147,7148],{"class":83,"line":210},[81,7149,109],{"emptyLinePlaceholder":108},[81,7151,7152,7155,7157],{"class":83,"line":215},[81,7153,7154],{"class":91},"  res.",[81,7156,2591],{"class":118},[81,7158,7159],{"class":91},"(enrichedProjects);\n",[81,7161,7162],{"class":83,"line":225},[81,7163,3495],{"class":91},[6996,7165,7167],{"id":7166},"the-optimized-pattern-sql-joins","The Optimized Pattern (SQL Joins)",[11,7169,7170],{},"Combine these into a single database execution:",[34,7172,7174],{"className":75,"code":7173,"language":77,"meta":40,"style":40},"app.get('\u002Fapi\u002Fprojects', async (req, res) => {\n  const query = `\n    SELECT p.*, row_to_json(u.*) as user \n    FROM projects p\n    LEFT JOIN users u ON p.userId = u.id\n  `;\n  const result = await db.query(query); \u002F\u002F 1 Single Query instead of N+1\n  res.json(result.rows);\n});\n",[42,7175,7176,7204,7216,7221,7226,7231,7238,7258,7267],{"__ignoreMap":40},[81,7177,7178,7180,7182,7184,7186,7188,7190,7192,7194,7196,7198,7200,7202],{"class":83,"line":84},[81,7179,5013],{"class":91},[81,7181,334],{"class":118},[81,7183,271],{"class":91},[81,7185,7020],{"class":98},[81,7187,281],{"class":91},[81,7189,5602],{"class":87},[81,7191,357],{"class":91},[81,7193,5053],{"class":128},[81,7195,281],{"class":91},[81,7197,5067],{"class":128},[81,7199,5079],{"class":91},[81,7201,1026],{"class":87},[81,7203,122],{"class":91},[81,7205,7206,7208,7211,7213],{"class":83,"line":105},[81,7207,303],{"class":87},[81,7209,7210],{"class":135}," query",[81,7212,309],{"class":87},[81,7214,7215],{"class":98}," `\n",[81,7217,7218],{"class":83,"line":112},[81,7219,7220],{"class":98},"    SELECT p.*, row_to_json(u.*) as user \n",[81,7222,7223],{"class":83,"line":125},[81,7224,7225],{"class":98},"    FROM projects p\n",[81,7227,7228],{"class":83,"line":141},[81,7229,7230],{"class":98},"    LEFT JOIN users u ON p.userId = u.id\n",[81,7232,7233,7236],{"class":83,"line":154},[81,7234,7235],{"class":98},"  `",[81,7237,102],{"class":91},[81,7239,7240,7242,7244,7246,7248,7250,7252,7255],{"class":83,"line":167},[81,7241,303],{"class":87},[81,7243,6656],{"class":135},[81,7245,309],{"class":87},[81,7247,4210],{"class":87},[81,7249,5763],{"class":91},[81,7251,5766],{"class":118},[81,7253,7254],{"class":91},"(query); ",[81,7256,7257],{"class":395},"\u002F\u002F 1 Single Query instead of N+1\n",[81,7259,7260,7262,7264],{"class":83,"line":179},[81,7261,7154],{"class":91},[81,7263,2591],{"class":118},[81,7265,7266],{"class":91},"(result.rows);\n",[81,7268,7269],{"class":83,"line":191},[81,7270,3495],{"class":91},[864,7272,7274],{"id":7273},"b-secret-management-and-environment-parity","B. Secret Management and Environment Parity",[11,7276,7277],{},"Check where API tokens, private keys, and passwords are stored.",[1296,7279,7280,7294],{},[1236,7281,7282,7285,7286,7289,7290,7293],{},[15,7283,7284],{},"Zero Hardcoding",": Ensure no ",[42,7287,7288],{},".env"," files or raw strings are committed to Git. Use ",[42,7291,7292],{},"git-secrets"," or Snyk to scan Git history for leaked credentials.",[1236,7295,7296,7299,7300,7303],{},[15,7297,7298],{},"Configuration Validation",": Use a library like ",[42,7301,7302],{},"zod"," to validate that all required environment variables are set at startup:",[34,7305,7307],{"className":75,"code":7306,"language":77,"meta":40,"style":40},"import { z } from 'zod';\n\nconst envSchema = z.object({\n  DATABASE_URL: z.string().url(),\n  PORT: z.string().transform(Number),\n  STRIPE_SECRET_KEY: z.string().min(10),\n});\n\nexport const ENV = envSchema.parse(process.env);\n",[42,7308,7309,7323,7327,7344,7360,7375,7395,7399,7403],{"__ignoreMap":40},[81,7310,7311,7313,7316,7318,7321],{"class":83,"line":84},[81,7312,88],{"class":87},[81,7314,7315],{"class":91}," { z } ",[81,7317,95],{"class":87},[81,7319,7320],{"class":98}," 'zod'",[81,7322,102],{"class":91},[81,7324,7325],{"class":83,"line":105},[81,7326,109],{"emptyLinePlaceholder":108},[81,7328,7329,7331,7334,7336,7339,7342],{"class":83,"line":112},[81,7330,4070],{"class":87},[81,7332,7333],{"class":135}," envSchema",[81,7335,309],{"class":87},[81,7337,7338],{"class":91}," z.",[81,7340,7341],{"class":118},"object",[81,7343,891],{"class":91},[81,7345,7346,7349,7351,7354,7357],{"class":83,"line":125},[81,7347,7348],{"class":91},"  DATABASE_URL: z.",[81,7350,4195],{"class":118},[81,7352,7353],{"class":91},"().",[81,7355,7356],{"class":118},"url",[81,7358,7359],{"class":91},"(),\n",[81,7361,7362,7365,7367,7369,7372],{"class":83,"line":141},[81,7363,7364],{"class":91},"  PORT: z.",[81,7366,4195],{"class":118},[81,7368,7353],{"class":91},[81,7370,7371],{"class":118},"transform",[81,7373,7374],{"class":91},"(Number),\n",[81,7376,7377,7380,7382,7384,7387,7389,7392],{"class":83,"line":154},[81,7378,7379],{"class":91},"  STRIPE_SECRET_KEY: z.",[81,7381,4195],{"class":118},[81,7383,7353],{"class":91},[81,7385,7386],{"class":118},"min",[81,7388,271],{"class":91},[81,7390,7391],{"class":135},"10",[81,7393,7394],{"class":91},"),\n",[81,7396,7397],{"class":83,"line":167},[81,7398,3495],{"class":91},[81,7400,7401],{"class":83,"line":179},[81,7402,109],{"emptyLinePlaceholder":108},[81,7404,7405,7407,7410,7413,7415,7418,7420],{"class":83,"line":191},[81,7406,262],{"class":87},[81,7408,7409],{"class":87}," const",[81,7411,7412],{"class":135}," ENV",[81,7414,309],{"class":87},[81,7416,7417],{"class":91}," envSchema.",[81,7419,799],{"class":118},[81,7421,7422],{"class":91},"(process.env);\n",[864,7424,7426],{"id":7425},"c-security-and-input-sanitization","C. Security and Input Sanitization",[11,7428,7429],{},"Inspect HTTP route entrypoints.",[1296,7431,7432,7446],{},[1236,7433,7434,7437,7438,7441,7442,7445],{},[15,7435,7436],{},"SQL Injection Prevention",": Ensure the codebase never concatenates strings directly into SQL statements (e.g., ",[42,7439,7440],{},"db.query(\"SELECT * FROM users WHERE id = \" + userId)","). Always utilize parameterized queries (",[42,7443,7444],{},"db.query('SELECT * FROM users WHERE id = $1', [userId])",") or verified ORMs.",[1236,7447,7448,7451,7452,7455],{},[15,7449,7450],{},"HTTP Security Headers",": Verify that the application uses the ",[42,7453,7454],{},"helmet"," package to automatically inject security headers (CSP, HSTS, X-Frame-Options) to protect against cross-site scripting (XSS) and clickjacking.",[26,7457,7459],{"id":7458},"_3-organizing-the-refactoring-plan","3. Organizing the Refactoring Plan",[11,7461,7462],{},"After completing the codebase audit, document the technical debt and prioritize fixes into three buckets:",[1233,7464,7465,7471,7477],{},[1236,7466,7467,7470],{},[15,7468,7469],{},"Blocker Issues (P0)",": Security risks, hardcoded API secrets, lack of backups, or broken authentication layers. Fix immediately before writing new features.",[1236,7472,7473,7476],{},[15,7474,7475],{},"Performance Bottlenecks (P1)",": Missing indexes on primary DB keys, N+1 queries, or slow third-party API calls lacking cache layers. Plan to refactor in the next sprint.",[1236,7478,7479,7482],{},[15,7480,7481],{},"Technical Debt Cleanup (P2)",": Formatting errors, lack of comments, code duplication, and low test coverage. Allocate 20% of every development sprint to address these.",[26,7484,1294],{"id":1293},[1296,7486,7487,7493,7500],{},[1236,7488,7489,7492],{},[1274,7490,4770],{"href":2569,"rel":7491},[1304]," — the reference list of web vulnerabilities for the security block",[1236,7494,7495,7499],{},[1274,7496,6928],{"href":7497,"rel":7498},"https:\u002F\u002Fdocs.npmjs.com\u002Fcli\u002Fcommands\u002Fnpm-audit",[1304]," — checking dependencies for known vulnerabilities",[1236,7501,7502,7507],{},[1274,7503,7506],{"href":7504,"rel":7505},"https:\u002F\u002Fwww.postgresql.org\u002Fdocs\u002Fcurrent\u002Findexes.html",[1304],"PostgreSQL: Indexes"," — the official indexing guide",[11,7509,7510,7511,62],{},"Regular technical codebase audits are key to maintaining developer velocity, reducing server overhead, and ensuring your SaaS scales seamlessly. Performing these audits is especially important if you suspect your code was rushed by budget agencies; read my analysis on ",[1274,7512,4510],{"href":3789},[11,7514,7515,7516,7519],{},"If you are taking over a project and need a professional third-party code audit, an architectural review, or want to establish code quality rules for your engineering team, visit my ",[1274,7517,7518],{"href":1328},"Technical Consultations Service"," page or book a session.",[1332,7521,7522],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}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 .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":7524},[7525,7526,7531,7532],{"id":6892,"depth":105,"text":6893},{"id":6986,"depth":105,"text":6987,"children":7527},[7528,7529,7530],{"id":6990,"depth":112,"text":6991},{"id":7273,"depth":112,"text":7274},{"id":7425,"depth":112,"text":7426},{"id":7458,"depth":105,"text":7459},{"id":1293,"depth":105,"text":1294},"2026-06-07","A practical senior developer playbook for auditing legacy Node.js\u002FTypeScript codebases, verifying security, mapping DB indexes, and identifying N+1 query patterns.",[7536,7539,7542],{"q":7537,"a":7538},"When does a codebase need a technical audit?","At three critical points: taking over a legacy project from another team, acquiring an existing product, and right before an active scaling phase. An audit in week 2–3 of a new project is cheaper than any audit after release.",{"q":7540,"a":7541},"What gets checked first?","Automated metrics first (dependency vulnerabilities via npm audit, linting, test coverage), then manual points: parameterized SQL queries, indexes on foreign keys, N+1 patterns, secrets in the repository, and error handling.",{"q":7543,"a":7544},"What do I do with the findings?","Split them by priority: P0 — security and backups, fix immediately; P1 — performance (indexes, N+1, caching), next sprint; P2 — code cleanup, a steady 20% of every sprint.","\u002Fimages\u002Fblog\u002Fblog_code_audit.jpg",{},"\u002Fblog\u002Fen\u002Fcode-audit-technical-debt",{"title":6872,"description":7534},"blog\u002Fen\u002Fcode-audit-technical-debt",[4505,7551,7552,4808,7553],"CodeReview","Security","TechDebt","wJlotU-vQSdsI_33Wk2T1Tc57sQkVYIQMVLxG_NGaPY",{"id":7556,"title":7557,"body":7558,"date":8961,"description":8962,"extension":1348,"faq":8963,"image":8973,"lang":1360,"meta":8974,"navigation":108,"path":8975,"published":108,"readTime":215,"seo":8976,"stem":8977,"tags":8978,"updated":1346,"__hash__":8981},"blog\u002Fblog\u002Fen\u002Ftelegram-bot-ai-rag-support.md","AI-Powered Customer Support: Building a RAG Bot with Live Agent Handover",{"type":8,"value":7559,"toc":8953},[7560,7563,7574,7581,7588,7592,7598,7602,7612,7682,7685,7769,7773,7776,8187,8191,8194,8446,8450,8457,8896,8902,8913,8915,8940,8943,8950],[11,7561,7562],{},"Deploying large language models (LLMs) like ChatGPT to answer client support queries is a highly effective way to reduce operational costs. However, standard LLMs suffer from two major flaws in corporate environments: they have no access to your private company data (like API documentation, refund guidelines, or custom plans), and they occasionally hallucinate incorrect information.",[11,7564,7565,7566,7569,7570,7573],{},"To solve this, modern production architectures use the ",[15,7567,7568],{},"RAG (Retrieval-Augmented Generation)"," framework. RAG restricts the AI to search a local database first, extract relevant knowledge blocks, and answer the user query based ",[2414,7571,7572],{},"strictly"," on that retrieved text.",[11,7575,7576,7577,7580],{},"Furthermore, to maintain high brand standards, your support system must implement a ",[15,7578,7579],{},"live agent handover protocol"," to route the conversation to a human support manager the second the AI struggles to find an answer.",[11,7582,7583,7584,7587],{},"In this developer guide, we will build a complete RAG execution loop using Node.js, PostgreSQL ",[42,7585,7586],{},"pgvector",", and a hybrid user-to-manager routing machine.",[26,7589,7591],{"id":7590},"technical-rag-workflow","Technical RAG Workflow",[34,7593,7596],{"className":7594,"code":7595,"language":39,"meta":40},[37],"[User asks: \"How to connect Stripe?\"]\n               │\n               v\n  (Generate Vector Embedding)\n  [text-embedding-3-small] (1536 dim)\n               │\n               v\n  (Vector Search in PostgreSQL)\n  SELECT * FROM match_kb_docs(vector, threshold = 0.75)\n               │\n               +----------------------+\n               |                      |\n      (Context Found)        (Context NOT Found \u002F Low Score)\n               │                      │\n               v                      v\n  [Assemble Prompt with Docs]   [Set Session = human_handling]\n               │                      │\n               v                      v\n  [Send to gpt-4o-mini]         [Notify Support Team in Slack]\n               │                      │\n               v                      v\n    [Send Answer to User]        [Alert user: \"Transferring...\"]\n",[42,7597,7595],{"__ignoreMap":40},[26,7599,7601],{"id":7600},"_1-setting-up-pgvector-database","1. Setting Up Pgvector Database",[11,7603,7604,7605,7608,7609,443],{},"First, enable the ",[42,7606,7607],{},"vector"," extension in PostgreSQL and create a table to store documentation chunks and their corresponding embedding coordinates (dimensions must match the embedding model, e.g., 1536 dimensions for OpenAI's ",[42,7610,7611],{},"text-embedding-3-small",[34,7613,7615],{"className":6358,"code":7614,"language":6360,"meta":40,"style":40},"-- Enable vector support extension\nCREATE EXTENSION IF NOT EXISTS vector;\n\n-- Create Knowledge Base table\nCREATE TABLE kb_documents (\n  id SERIAL PRIMARY KEY,\n  content TEXT NOT NULL,\n  embedding VECTOR(1536) NOT NULL,\n  category VARCHAR(50) DEFAULT 'general',\n  created_at TIMESTAMP DEFAULT NOW()\n);\n\n-- Index vector column for fast search (HNSW index recommended for scalability)\nCREATE INDEX kb_hnsw_idx ON kb_documents USING hnsw (embedding vector_cosine_ops);\n",[42,7616,7617,7622,7627,7631,7636,7641,7645,7650,7655,7660,7664,7668,7672,7677],{"__ignoreMap":40},[81,7618,7619],{"class":83,"line":84},[81,7620,7621],{},"-- Enable vector support extension\n",[81,7623,7624],{"class":83,"line":105},[81,7625,7626],{},"CREATE EXTENSION IF NOT EXISTS vector;\n",[81,7628,7629],{"class":83,"line":112},[81,7630,109],{"emptyLinePlaceholder":108},[81,7632,7633],{"class":83,"line":125},[81,7634,7635],{},"-- Create Knowledge Base table\n",[81,7637,7638],{"class":83,"line":141},[81,7639,7640],{},"CREATE TABLE kb_documents (\n",[81,7642,7643],{"class":83,"line":154},[81,7644,6377],{},[81,7646,7647],{"class":83,"line":167},[81,7648,7649],{},"  content TEXT NOT NULL,\n",[81,7651,7652],{"class":83,"line":179},[81,7653,7654],{},"  embedding VECTOR(1536) NOT NULL,\n",[81,7656,7657],{"class":83,"line":191},[81,7658,7659],{},"  category VARCHAR(50) DEFAULT 'general',\n",[81,7661,7662],{"class":83,"line":204},[81,7663,6387],{},[81,7665,7666],{"class":83,"line":210},[81,7667,342],{},[81,7669,7670],{"class":83,"line":215},[81,7671,109],{"emptyLinePlaceholder":108},[81,7673,7674],{"class":83,"line":225},[81,7675,7676],{},"-- Index vector column for fast search (HNSW index recommended for scalability)\n",[81,7678,7679],{"class":83,"line":237},[81,7680,7681],{},"CREATE INDEX kb_hnsw_idx ON kb_documents USING hnsw (embedding vector_cosine_ops);\n",[11,7683,7684],{},"Next, create the SQL function to retrieve documents based on cosine similarity:",[34,7686,7688],{"className":6358,"code":7687,"language":6360,"meta":40,"style":40},"CREATE OR REPLACE FUNCTION match_kb_docs(\n  query_embedding VECTOR(1536),\n  match_threshold FLOAT,\n  match_count INT\n)\nRETURNS TABLE (id INT, content TEXT, similarity FLOAT)\nLANGUAGE plpgsql AS $$\nBEGIN\n  RETURN QUERY\n  SELECT kb.id, kb.content, 1 - (kb.embedding \u003C=> query_embedding) AS similarity\n  FROM kb_documents kb\n  WHERE 1 - (kb.embedding \u003C=> query_embedding) > match_threshold\n  ORDER BY kb.embedding \u003C=> query_embedding\n  LIMIT match_count;\nEND;\n$$;\n",[42,7689,7690,7695,7700,7705,7710,7714,7719,7724,7729,7734,7739,7744,7749,7754,7759,7764],{"__ignoreMap":40},[81,7691,7692],{"class":83,"line":84},[81,7693,7694],{},"CREATE OR REPLACE FUNCTION match_kb_docs(\n",[81,7696,7697],{"class":83,"line":105},[81,7698,7699],{},"  query_embedding VECTOR(1536),\n",[81,7701,7702],{"class":83,"line":112},[81,7703,7704],{},"  match_threshold FLOAT,\n",[81,7706,7707],{"class":83,"line":125},[81,7708,7709],{},"  match_count INT\n",[81,7711,7712],{"class":83,"line":141},[81,7713,515],{},[81,7715,7716],{"class":83,"line":154},[81,7717,7718],{},"RETURNS TABLE (id INT, content TEXT, similarity FLOAT)\n",[81,7720,7721],{"class":83,"line":167},[81,7722,7723],{},"LANGUAGE plpgsql AS $$\n",[81,7725,7726],{"class":83,"line":179},[81,7727,7728],{},"BEGIN\n",[81,7730,7731],{"class":83,"line":191},[81,7732,7733],{},"  RETURN QUERY\n",[81,7735,7736],{"class":83,"line":204},[81,7737,7738],{},"  SELECT kb.id, kb.content, 1 - (kb.embedding \u003C=> query_embedding) AS similarity\n",[81,7740,7741],{"class":83,"line":210},[81,7742,7743],{},"  FROM kb_documents kb\n",[81,7745,7746],{"class":83,"line":215},[81,7747,7748],{},"  WHERE 1 - (kb.embedding \u003C=> query_embedding) > match_threshold\n",[81,7750,7751],{"class":83,"line":225},[81,7752,7753],{},"  ORDER BY kb.embedding \u003C=> query_embedding\n",[81,7755,7756],{"class":83,"line":237},[81,7757,7758],{},"  LIMIT match_count;\n",[81,7760,7761],{"class":83,"line":249},[81,7762,7763],{},"END;\n",[81,7765,7766],{"class":83,"line":254},[81,7767,7768],{},"$$;\n",[26,7770,7772],{"id":7771},"_2-implementing-rag-fetching-in-nodejs","2. Implementing RAG Fetching in Node.js",[11,7774,7775],{},"We write a service to generate embeddings for user questions via the OpenAI API and execute the vector search.",[34,7777,7779],{"className":75,"code":7778,"language":77,"meta":40,"style":40},"import { OpenAI } from 'openai';\nimport pg from 'pg';\n\nconst openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });\nconst pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });\n\nexport async function getEmbedding(text: string): Promise\u003Cnumber[]> {\n  const response = await openai.embeddings.create({\n    model: 'text-embedding-3-small',\n    input: text.replace(\u002F\\n\u002Fg, ' '),\n  });\n  return response.data[0].embedding;\n}\n\nexport async function findRelevantContext(queryText: string): Promise\u003Cstring> {\n  const queryVector = await getEmbedding(queryText);\n  const sql = `SELECT * FROM match_kb_docs($1::vector, 0.75, 3)`;\n  \n  const client = await pool.connect();\n  try {\n    const result = await client.query(sql, [JSON.stringify(queryVector)]);\n    if (result.rows.length === 0) {\n      return '';\n    }\n    \u002F\u002F Combine top matches into a single text block\n    return result.rows.map(row => row.content).join('\\n---\\n');\n  } finally {\n    client.release();\n  }\n}\n",[42,7780,7781,7795,7808,7812,7834,7857,7861,7893,7911,7921,7948,7952,7964,7968,7972,8004,8020,8034,8038,8055,8061,8089,8107,8116,8120,8125,8161,8169,8179,8183],{"__ignoreMap":40},[81,7782,7783,7785,7788,7790,7793],{"class":83,"line":84},[81,7784,88],{"class":87},[81,7786,7787],{"class":91}," { OpenAI } ",[81,7789,95],{"class":87},[81,7791,7792],{"class":98}," 'openai'",[81,7794,102],{"class":91},[81,7796,7797,7799,7802,7804,7806],{"class":83,"line":105},[81,7798,88],{"class":87},[81,7800,7801],{"class":91}," pg ",[81,7803,95],{"class":87},[81,7805,6514],{"class":98},[81,7807,102],{"class":91},[81,7809,7810],{"class":83,"line":112},[81,7811,109],{"emptyLinePlaceholder":108},[81,7813,7814,7816,7819,7821,7823,7826,7829,7832],{"class":83,"line":125},[81,7815,4070],{"class":87},[81,7817,7818],{"class":135}," openai",[81,7820,309],{"class":87},[81,7822,312],{"class":87},[81,7824,7825],{"class":118}," OpenAI",[81,7827,7828],{"class":91},"({ apiKey: process.env.",[81,7830,7831],{"class":135},"OPENAI_API_KEY",[81,7833,5520],{"class":91},[81,7835,7836,7838,7841,7843,7845,7848,7851,7853,7855],{"class":83,"line":141},[81,7837,4070],{"class":87},[81,7839,7840],{"class":135}," pool",[81,7842,309],{"class":87},[81,7844,312],{"class":87},[81,7846,7847],{"class":91}," pg.",[81,7849,7850],{"class":118},"Pool",[81,7852,6567],{"class":91},[81,7854,6570],{"class":135},[81,7856,5520],{"class":91},[81,7858,7859],{"class":83,"line":154},[81,7860,109],{"emptyLinePlaceholder":108},[81,7862,7863,7865,7867,7869,7872,7874,7876,7878,7880,7882,7884,7886,7888,7891],{"class":83,"line":167},[81,7864,262],{"class":87},[81,7866,4169],{"class":87},[81,7868,265],{"class":87},[81,7870,7871],{"class":118}," getEmbedding",[81,7873,271],{"class":91},[81,7875,39],{"class":128},[81,7877,132],{"class":87},[81,7879,149],{"class":135},[81,7881,291],{"class":91},[81,7883,132],{"class":87},[81,7885,4190],{"class":118},[81,7887,1000],{"class":91},[81,7889,7890],{"class":135},"number",[81,7892,6550],{"class":91},[81,7894,7895,7897,7900,7902,7904,7907,7909],{"class":83,"line":179},[81,7896,303],{"class":87},[81,7898,7899],{"class":135}," response",[81,7901,309],{"class":87},[81,7903,4210],{"class":87},[81,7905,7906],{"class":91}," openai.embeddings.",[81,7908,4216],{"class":118},[81,7910,891],{"class":91},[81,7912,7913,7916,7919],{"class":83,"line":191},[81,7914,7915],{"class":91},"    model: ",[81,7917,7918],{"class":98},"'text-embedding-3-small'",[81,7920,2612],{"class":91},[81,7922,7923,7926,7929,7931,7934,7936,7938,7941,7943,7946],{"class":83,"line":204},[81,7924,7925],{"class":91},"    input: text.",[81,7927,7928],{"class":118},"replace",[81,7930,271],{"class":91},[81,7932,7933],{"class":98},"\u002F",[81,7935,531],{"class":135},[81,7937,7933],{"class":98},[81,7939,7940],{"class":87},"g",[81,7942,281],{"class":91},[81,7944,7945],{"class":98},"' '",[81,7947,7394],{"class":91},[81,7949,7950],{"class":83,"line":210},[81,7951,1198],{"class":91},[81,7953,7954,7956,7959,7961],{"class":83,"line":215},[81,7955,1207],{"class":87},[81,7957,7958],{"class":91}," response.data[",[81,7960,5714],{"class":135},[81,7962,7963],{"class":91},"].embedding;\n",[81,7965,7966],{"class":83,"line":225},[81,7967,207],{"class":91},[81,7969,7970],{"class":83,"line":237},[81,7971,109],{"emptyLinePlaceholder":108},[81,7973,7974,7976,7978,7980,7983,7985,7988,7990,7992,7994,7996,7998,8000,8002],{"class":83,"line":249},[81,7975,262],{"class":87},[81,7977,4169],{"class":87},[81,7979,265],{"class":87},[81,7981,7982],{"class":118}," findRelevantContext",[81,7984,271],{"class":91},[81,7986,7987],{"class":128},"queryText",[81,7989,132],{"class":87},[81,7991,149],{"class":135},[81,7993,291],{"class":91},[81,7995,132],{"class":87},[81,7997,4190],{"class":118},[81,7999,1000],{"class":91},[81,8001,4195],{"class":135},[81,8003,4198],{"class":91},[81,8005,8006,8008,8011,8013,8015,8017],{"class":83,"line":254},[81,8007,303],{"class":87},[81,8009,8010],{"class":135}," queryVector",[81,8012,309],{"class":87},[81,8014,4210],{"class":87},[81,8016,7871],{"class":118},[81,8018,8019],{"class":91},"(queryText);\n",[81,8021,8022,8024,8027,8029,8032],{"class":83,"line":259},[81,8023,303],{"class":87},[81,8025,8026],{"class":135}," sql",[81,8028,309],{"class":87},[81,8030,8031],{"class":98}," `SELECT * FROM match_kb_docs($1::vector, 0.75, 3)`",[81,8033,102],{"class":91},[81,8035,8036],{"class":83,"line":300},[81,8037,348],{"class":91},[81,8039,8040,8042,8044,8046,8048,8051,8053],{"class":83,"line":321},[81,8041,303],{"class":87},[81,8043,6557],{"class":135},[81,8045,309],{"class":87},[81,8047,4210],{"class":87},[81,8049,8050],{"class":91}," pool.",[81,8052,6582],{"class":118},[81,8054,449],{"class":91},[81,8056,8057,8059],{"class":83,"line":345},[81,8058,747],{"class":87},[81,8060,122],{"class":91},[81,8062,8063,8065,8067,8069,8071,8073,8075,8078,8081,8083,8086],{"class":83,"line":351},[81,8064,755],{"class":87},[81,8066,6656],{"class":135},[81,8068,309],{"class":87},[81,8070,4210],{"class":87},[81,8072,6579],{"class":91},[81,8074,5766],{"class":118},[81,8076,8077],{"class":91},"(sql, [",[81,8079,8080],{"class":135},"JSON",[81,8082,62],{"class":91},[81,8084,8085],{"class":118},"stringify",[81,8087,8088],{"class":91},"(queryVector)]);\n",[81,8090,8091,8093,8096,8099,8102,8105],{"class":83,"line":366},[81,8092,1054],{"class":87},[81,8094,8095],{"class":91}," (result.rows.",[81,8097,8098],{"class":135},"length",[81,8100,8101],{"class":87}," ===",[81,8103,8104],{"class":135}," 0",[81,8106,5223],{"class":91},[81,8108,8109,8111,8114],{"class":83,"line":381},[81,8110,5122],{"class":87},[81,8112,8113],{"class":98}," ''",[81,8115,102],{"class":91},[81,8117,8118],{"class":83,"line":387},[81,8119,1193],{"class":91},[81,8121,8122],{"class":83,"line":392},[81,8123,8124],{"class":395},"    \u002F\u002F Combine top matches into a single text block\n",[81,8126,8127,8129,8132,8134,8136,8139,8141,8144,8146,8148,8150,8152,8155,8157,8159],{"class":83,"line":399},[81,8128,369],{"class":87},[81,8130,8131],{"class":91}," result.rows.",[81,8133,482],{"class":118},[81,8135,271],{"class":91},[81,8137,8138],{"class":128},"row",[81,8140,431],{"class":87},[81,8142,8143],{"class":91}," row.content).",[81,8145,523],{"class":118},[81,8147,271],{"class":91},[81,8149,528],{"class":98},[81,8151,531],{"class":135},[81,8153,8154],{"class":98},"---",[81,8156,531],{"class":135},[81,8158,528],{"class":98},[81,8160,342],{"class":91},[81,8162,8163,8165,8167],{"class":83,"line":452},[81,8164,828],{"class":91},[81,8166,6734],{"class":87},[81,8168,122],{"class":91},[81,8170,8171,8174,8177],{"class":83,"line":457},[81,8172,8173],{"class":91},"    client.",[81,8175,8176],{"class":118},"release",[81,8178,449],{"class":91},[81,8180,8181],{"class":83,"line":463},[81,8182,384],{"class":91},[81,8184,8185],{"class":83,"line":476},[81,8186,207],{"class":91},[26,8188,8190],{"id":8189},"_3-strict-prompting-fallback-logic","3. Strict Prompting & Fallback Logic",[11,8192,8193],{},"To avoid AI hallucinations, we enforce strict rules inside the system prompt:",[34,8195,8197],{"className":75,"code":8196,"language":77,"meta":40,"style":40},"export async function generateAIResponse(userQuestion: string, context: string): Promise\u003Cstring> {\n  if (!context) {\n    return 'ERROR_UNRESOLVED';\n  }\n\n  const systemPrompt = `\nYou are a technical support assistant for TeleGo.io.\nAnswer the customer's question using ONLY the context provided below.\nRules:\n1. Rely ONLY on the context details. Do not use external knowledge or invent facts.\n2. If the context is insufficient or unrelated to the question, respond strictly with: \"ERROR_UNRESOLVED\".\n3. Maintain a polite, professional tone.\n\nContext:\n---\n${context}\n---\n`;\n\n  const response = await openai.chat.completions.create({\n    model: 'gpt-4o-mini',\n    messages: [\n      { role: 'system', content: systemPrompt },\n      { role: 'user', content: userQuestion }\n    ],\n    temperature: 0.0, \u002F\u002F Force deterministic outputs\n  });\n\n  return response.choices[0].message.content || 'ERROR_UNRESOLVED';\n}\n",[42,8198,8199,8240,8251,8260,8264,8268,8279,8284,8289,8294,8299,8304,8309,8313,8318,8323,8332,8336,8343,8347,8364,8373,8378,8389,8398,8402,8415,8419,8423,8442],{"__ignoreMap":40},[81,8200,8201,8203,8205,8207,8210,8212,8215,8217,8219,8221,8224,8226,8228,8230,8232,8234,8236,8238],{"class":83,"line":84},[81,8202,262],{"class":87},[81,8204,4169],{"class":87},[81,8206,265],{"class":87},[81,8208,8209],{"class":118}," generateAIResponse",[81,8211,271],{"class":91},[81,8213,8214],{"class":128},"userQuestion",[81,8216,132],{"class":87},[81,8218,149],{"class":135},[81,8220,281],{"class":91},[81,8222,8223],{"class":128},"context",[81,8225,132],{"class":87},[81,8227,149],{"class":135},[81,8229,291],{"class":91},[81,8231,132],{"class":87},[81,8233,4190],{"class":118},[81,8235,1000],{"class":91},[81,8237,4195],{"class":135},[81,8239,4198],{"class":91},[81,8241,8242,8244,8246,8248],{"class":83,"line":105},[81,8243,354],{"class":87},[81,8245,357],{"class":91},[81,8247,360],{"class":87},[81,8249,8250],{"class":91},"context) {\n",[81,8252,8253,8255,8258],{"class":83,"line":112},[81,8254,369],{"class":87},[81,8256,8257],{"class":98}," 'ERROR_UNRESOLVED'",[81,8259,102],{"class":91},[81,8261,8262],{"class":83,"line":125},[81,8263,384],{"class":91},[81,8265,8266],{"class":83,"line":141},[81,8267,109],{"emptyLinePlaceholder":108},[81,8269,8270,8272,8275,8277],{"class":83,"line":154},[81,8271,303],{"class":87},[81,8273,8274],{"class":135}," systemPrompt",[81,8276,309],{"class":87},[81,8278,7215],{"class":98},[81,8280,8281],{"class":83,"line":167},[81,8282,8283],{"class":98},"You are a technical support assistant for TeleGo.io.\n",[81,8285,8286],{"class":83,"line":179},[81,8287,8288],{"class":98},"Answer the customer's question using ONLY the context provided below.\n",[81,8290,8291],{"class":83,"line":191},[81,8292,8293],{"class":98},"Rules:\n",[81,8295,8296],{"class":83,"line":204},[81,8297,8298],{"class":98},"1. Rely ONLY on the context details. Do not use external knowledge or invent facts.\n",[81,8300,8301],{"class":83,"line":210},[81,8302,8303],{"class":98},"2. If the context is insufficient or unrelated to the question, respond strictly with: \"ERROR_UNRESOLVED\".\n",[81,8305,8306],{"class":83,"line":215},[81,8307,8308],{"class":98},"3. Maintain a polite, professional tone.\n",[81,8310,8311],{"class":83,"line":225},[81,8312,109],{"emptyLinePlaceholder":108},[81,8314,8315],{"class":83,"line":237},[81,8316,8317],{"class":98},"Context:\n",[81,8319,8320],{"class":83,"line":249},[81,8321,8322],{"class":98},"---\n",[81,8324,8325,8328,8330],{"class":83,"line":254},[81,8326,8327],{"class":98},"${",[81,8329,8223],{"class":91},[81,8331,207],{"class":98},[81,8333,8334],{"class":83,"line":259},[81,8335,8322],{"class":98},[81,8337,8338,8341],{"class":83,"line":300},[81,8339,8340],{"class":98},"`",[81,8342,102],{"class":91},[81,8344,8345],{"class":83,"line":321},[81,8346,109],{"emptyLinePlaceholder":108},[81,8348,8349,8351,8353,8355,8357,8360,8362],{"class":83,"line":345},[81,8350,303],{"class":87},[81,8352,7899],{"class":135},[81,8354,309],{"class":87},[81,8356,4210],{"class":87},[81,8358,8359],{"class":91}," openai.chat.completions.",[81,8361,4216],{"class":118},[81,8363,891],{"class":91},[81,8365,8366,8368,8371],{"class":83,"line":351},[81,8367,7915],{"class":91},[81,8369,8370],{"class":98},"'gpt-4o-mini'",[81,8372,2612],{"class":91},[81,8374,8375],{"class":83,"line":366},[81,8376,8377],{"class":91},"    messages: [\n",[81,8379,8380,8383,8386],{"class":83,"line":381},[81,8381,8382],{"class":91},"      { role: ",[81,8384,8385],{"class":98},"'system'",[81,8387,8388],{"class":91},", content: systemPrompt },\n",[81,8390,8391,8393,8395],{"class":83,"line":387},[81,8392,8382],{"class":91},[81,8394,769],{"class":98},[81,8396,8397],{"class":91},", content: userQuestion }\n",[81,8399,8400],{"class":83,"line":392},[81,8401,4262],{"class":91},[81,8403,8404,8407,8410,8412],{"class":83,"line":399},[81,8405,8406],{"class":91},"    temperature: ",[81,8408,8409],{"class":135},"0.0",[81,8411,281],{"class":91},[81,8413,8414],{"class":395},"\u002F\u002F Force deterministic outputs\n",[81,8416,8417],{"class":83,"line":452},[81,8418,1198],{"class":91},[81,8420,8421],{"class":83,"line":457},[81,8422,109],{"emptyLinePlaceholder":108},[81,8424,8425,8427,8430,8432,8435,8438,8440],{"class":83,"line":463},[81,8426,1207],{"class":87},[81,8428,8429],{"class":91}," response.choices[",[81,8431,5714],{"class":135},[81,8433,8434],{"class":91},"].message.content ",[81,8436,8437],{"class":87},"||",[81,8439,8257],{"class":98},[81,8441,102],{"class":91},[81,8443,8444],{"class":83,"line":476},[81,8445,207],{"class":91},[26,8447,8449],{"id":8448},"_4-chat-routing-and-human-handover","4. Chat Routing and Human Handover",[11,8451,8452,8453,8456],{},"When a message is received in the Telegram bot, we check if the user is currently flagged as speaking to a human manager. If not, we run the RAG query. If the RAG engine returns ",[42,8454,8455],{},"ERROR_UNRESOLVED",", we transfer the session.",[34,8458,8460],{"className":75,"code":8459,"language":77,"meta":40,"style":40},"import TelegramBot from 'node-telegram-bot-api';\n\nconst bot = new TelegramBot(process.env.TELEGRAM_BOT_TOKEN!, { polling: true });\n\nbot.on('message', async (msg) => {\n  const chatId = msg.chat.id;\n  const userText = msg.text;\n\n  if (!userText) return;\n\n  \u002F\u002F 1. Get current session status from DB\n  const session = await db.getChatSession(chatId);\n\n  \u002F\u002F If in human handling, forward messages to manager Slack\u002FTelegram\n  if (session?.status === 'human_handling') {\n    await forwardToManagers(chatId, msg);\n    return;\n  }\n\n  \u002F\u002F 2. Perform RAG query\n  try {\n    const context = await findRelevantContext(userText);\n    const aiResponse = await generateAIResponse(userText, context);\n\n    \u002F\u002F 3. Escalation check\n    if (aiResponse.trim() === 'ERROR_UNRESOLVED') {\n      \u002F\u002F Set session to human handling in DB\n      await db.updateChatSession(chatId, { status: 'human_handling' });\n      \n      \u002F\u002F Notify support staff via webhook\n      await notifySupportStaff(chatId, userText);\n      \n      await bot.sendMessage(\n        chatId, \n        \"I couldn't find the answer in our documentation. I've transferred your chat to our support team. A representative will respond shortly.\"\n      );\n      return;\n    }\n\n    \u002F\u002F 4. Send AI answer to user\n    await bot.sendMessage(chatId, aiResponse);\n\n  } catch (error) {\n    console.error('RAG workflow error:', error);\n    await bot.sendMessage(chatId, \"Something went wrong. Let me transfer you to a human manager.\");\n    await db.updateChatSession(chatId, { status: 'human_handling' });\n  }\n});\n",[42,8461,8462,8476,8480,8508,8512,8540,8552,8564,8568,8583,8587,8592,8610,8614,8619,8633,8643,8649,8653,8657,8662,8668,8684,8700,8704,8709,8728,8733,8751,8755,8760,8770,8774,8786,8791,8796,8801,8807,8811,8815,8820,8831,8835,8843,8858,8874,8888,8892],{"__ignoreMap":40},[81,8463,8464,8466,8469,8471,8474],{"class":83,"line":84},[81,8465,88],{"class":87},[81,8467,8468],{"class":91}," TelegramBot ",[81,8470,95],{"class":87},[81,8472,8473],{"class":98}," 'node-telegram-bot-api'",[81,8475,102],{"class":91},[81,8477,8478],{"class":83,"line":105},[81,8479,109],{"emptyLinePlaceholder":108},[81,8481,8482,8484,8487,8489,8491,8494,8496,8499,8501,8504,8506],{"class":83,"line":112},[81,8483,4070],{"class":87},[81,8485,8486],{"class":135}," bot",[81,8488,309],{"class":87},[81,8490,312],{"class":87},[81,8492,8493],{"class":118}," TelegramBot",[81,8495,4083],{"class":91},[81,8497,8498],{"class":135},"TELEGRAM_BOT_TOKEN",[81,8500,360],{"class":87},[81,8502,8503],{"class":91},", { polling: ",[81,8505,819],{"class":135},[81,8507,5520],{"class":91},[81,8509,8510],{"class":83,"line":125},[81,8511,109],{"emptyLinePlaceholder":108},[81,8513,8514,8517,8520,8522,8525,8527,8529,8531,8534,8536,8538],{"class":83,"line":141},[81,8515,8516],{"class":91},"bot.",[81,8518,8519],{"class":118},"on",[81,8521,271],{"class":91},[81,8523,8524],{"class":98},"'message'",[81,8526,281],{"class":91},[81,8528,5602],{"class":87},[81,8530,357],{"class":91},[81,8532,8533],{"class":128},"msg",[81,8535,5079],{"class":91},[81,8537,1026],{"class":87},[81,8539,122],{"class":91},[81,8541,8542,8544,8547,8549],{"class":83,"line":154},[81,8543,303],{"class":87},[81,8545,8546],{"class":135}," chatId",[81,8548,309],{"class":87},[81,8550,8551],{"class":91}," msg.chat.id;\n",[81,8553,8554,8556,8559,8561],{"class":83,"line":167},[81,8555,303],{"class":87},[81,8557,8558],{"class":135}," userText",[81,8560,309],{"class":87},[81,8562,8563],{"class":91}," msg.text;\n",[81,8565,8566],{"class":83,"line":179},[81,8567,109],{"emptyLinePlaceholder":108},[81,8569,8570,8572,8574,8576,8579,8581],{"class":83,"line":191},[81,8571,354],{"class":87},[81,8573,357],{"class":91},[81,8575,360],{"class":87},[81,8577,8578],{"class":91},"userText) ",[81,8580,5889],{"class":87},[81,8582,102],{"class":91},[81,8584,8585],{"class":83,"line":204},[81,8586,109],{"emptyLinePlaceholder":108},[81,8588,8589],{"class":83,"line":210},[81,8590,8591],{"class":395},"  \u002F\u002F 1. Get current session status from DB\n",[81,8593,8594,8596,8598,8600,8602,8604,8607],{"class":83,"line":215},[81,8595,303],{"class":87},[81,8597,4205],{"class":135},[81,8599,309],{"class":87},[81,8601,4210],{"class":87},[81,8603,5763],{"class":91},[81,8605,8606],{"class":118},"getChatSession",[81,8608,8609],{"class":91},"(chatId);\n",[81,8611,8612],{"class":83,"line":225},[81,8613,109],{"emptyLinePlaceholder":108},[81,8615,8616],{"class":83,"line":237},[81,8617,8618],{"class":395},"  \u002F\u002F If in human handling, forward messages to manager Slack\u002FTelegram\n",[81,8620,8621,8623,8626,8628,8631],{"class":83,"line":249},[81,8622,354],{"class":87},[81,8624,8625],{"class":91}," (session?.status ",[81,8627,694],{"class":87},[81,8629,8630],{"class":98}," 'human_handling'",[81,8632,5223],{"class":91},[81,8634,8635,8637,8640],{"class":83,"line":254},[81,8636,6604],{"class":87},[81,8638,8639],{"class":118}," forwardToManagers",[81,8641,8642],{"class":91},"(chatId, msg);\n",[81,8644,8645,8647],{"class":83,"line":259},[81,8646,369],{"class":87},[81,8648,102],{"class":91},[81,8650,8651],{"class":83,"line":300},[81,8652,384],{"class":91},[81,8654,8655],{"class":83,"line":321},[81,8656,109],{"emptyLinePlaceholder":108},[81,8658,8659],{"class":83,"line":345},[81,8660,8661],{"class":395},"  \u002F\u002F 2. Perform RAG query\n",[81,8663,8664,8666],{"class":83,"line":351},[81,8665,747],{"class":87},[81,8667,122],{"class":91},[81,8669,8670,8672,8675,8677,8679,8681],{"class":83,"line":366},[81,8671,755],{"class":87},[81,8673,8674],{"class":135}," context",[81,8676,309],{"class":87},[81,8678,4210],{"class":87},[81,8680,7982],{"class":118},[81,8682,8683],{"class":91},"(userText);\n",[81,8685,8686,8688,8691,8693,8695,8697],{"class":83,"line":381},[81,8687,755],{"class":87},[81,8689,8690],{"class":135}," aiResponse",[81,8692,309],{"class":87},[81,8694,4210],{"class":87},[81,8696,8209],{"class":118},[81,8698,8699],{"class":91},"(userText, context);\n",[81,8701,8702],{"class":83,"line":387},[81,8703,109],{"emptyLinePlaceholder":108},[81,8705,8706],{"class":83,"line":392},[81,8707,8708],{"class":395},"    \u002F\u002F 3. Escalation check\n",[81,8710,8711,8713,8716,8719,8722,8724,8726],{"class":83,"line":399},[81,8712,1054],{"class":87},[81,8714,8715],{"class":91}," (aiResponse.",[81,8717,8718],{"class":118},"trim",[81,8720,8721],{"class":91},"() ",[81,8723,694],{"class":87},[81,8725,8257],{"class":98},[81,8727,5223],{"class":91},[81,8729,8730],{"class":83,"line":452},[81,8731,8732],{"class":395},"      \u002F\u002F Set session to human handling in DB\n",[81,8734,8735,8738,8740,8743,8746,8749],{"class":83,"line":457},[81,8736,8737],{"class":87},"      await",[81,8739,5763],{"class":91},[81,8741,8742],{"class":118},"updateChatSession",[81,8744,8745],{"class":91},"(chatId, { status: ",[81,8747,8748],{"class":98},"'human_handling'",[81,8750,5520],{"class":91},[81,8752,8753],{"class":83,"line":463},[81,8754,1085],{"class":91},[81,8756,8757],{"class":83,"line":476},[81,8758,8759],{"class":395},"      \u002F\u002F Notify support staff via webhook\n",[81,8761,8762,8764,8767],{"class":83,"line":518},[81,8763,8737],{"class":87},[81,8765,8766],{"class":118}," notifySupportStaff",[81,8768,8769],{"class":91},"(chatId, userText);\n",[81,8771,8772],{"class":83,"line":538},[81,8773,1085],{"class":91},[81,8775,8776,8778,8781,8784],{"class":83,"line":543},[81,8777,8737],{"class":87},[81,8779,8780],{"class":91}," bot.",[81,8782,8783],{"class":118},"sendMessage",[81,8785,5019],{"class":91},[81,8787,8788],{"class":83,"line":549},[81,8789,8790],{"class":91},"        chatId, \n",[81,8792,8793],{"class":83,"line":555},[81,8794,8795],{"class":98},"        \"I couldn't find the answer in our documentation. I've transferred your chat to our support team. A representative will respond shortly.\"\n",[81,8797,8798],{"class":83,"line":568},[81,8799,8800],{"class":91},"      );\n",[81,8802,8803,8805],{"class":83,"line":588},[81,8804,5122],{"class":87},[81,8806,102],{"class":91},[81,8808,8809],{"class":83,"line":599},[81,8810,1193],{"class":91},[81,8812,8813],{"class":83,"line":609},[81,8814,109],{"emptyLinePlaceholder":108},[81,8816,8817],{"class":83,"line":614},[81,8818,8819],{"class":395},"    \u002F\u002F 4. Send AI answer to user\n",[81,8821,8822,8824,8826,8828],{"class":83,"line":620},[81,8823,6604],{"class":87},[81,8825,8780],{"class":91},[81,8827,8783],{"class":118},[81,8829,8830],{"class":91},"(chatId, aiResponse);\n",[81,8832,8833],{"class":83,"line":632},[81,8834,109],{"emptyLinePlaceholder":108},[81,8836,8837,8839,8841],{"class":83,"line":646},[81,8838,828],{"class":91},[81,8840,831],{"class":87},[81,8842,834],{"class":91},[81,8844,8845,8848,8850,8852,8855],{"class":83,"line":656},[81,8846,8847],{"class":91},"    console.",[81,8849,5231],{"class":118},[81,8851,271],{"class":91},[81,8853,8854],{"class":98},"'RAG workflow error:'",[81,8856,8857],{"class":91},", error);\n",[81,8859,8860,8862,8864,8866,8869,8872],{"class":83,"line":670},[81,8861,6604],{"class":87},[81,8863,8780],{"class":91},[81,8865,8783],{"class":118},[81,8867,8868],{"class":91},"(chatId, ",[81,8870,8871],{"class":98},"\"Something went wrong. Let me transfer you to a human manager.\"",[81,8873,342],{"class":91},[81,8875,8876,8878,8880,8882,8884,8886],{"class":83,"line":675},[81,8877,6604],{"class":87},[81,8879,5763],{"class":91},[81,8881,8742],{"class":118},[81,8883,8745],{"class":91},[81,8885,8748],{"class":98},[81,8887,5520],{"class":91},[81,8889,8890],{"class":83,"line":681},[81,8891,384],{"class":91},[81,8893,8894],{"class":83,"line":700},[81,8895,3495],{"class":91},[11,8897,8898,8899,8901],{},"In my project ",[1274,8900,1277],{"href":1276},", we built a similar RAG system to handle initial customer questions regarding billing or scenario configurations. When a user asks a complex technical question that isn't answered in our docs, the system automatically redirects the ticket to the manager dashboard.",[11,8903,8904,8905,8909,8910,62],{},"This escalation flow is even more powerful when paired with ",[1274,8906,8908],{"href":8907},"\u002Fblog\u002Ftelegram-bot-crm-integration","Telegram Bot CRM Integrations"," to automatically log these events. Furthermore, you can host the manager communication dashboard directly inside a ",[1274,8911,8912],{"href":6084},"Telegram Web App",[26,8914,1294],{"id":1293},[1296,8916,8917,8925,8932],{},[1236,8918,8919,8924],{},[1274,8920,8923],{"href":8921,"rel":8922},"https:\u002F\u002Fplatform.openai.com\u002Fdocs\u002Fguides\u002Fembeddings",[1304],"OpenAI Embeddings"," — vector generation, including the text-embedding-3-small model from the example",[1236,8926,8927,8931],{},[1274,8928,7586],{"href":8929,"rel":8930},"https:\u002F\u002Fgithub.com\u002Fpgvector\u002Fpgvector",[1304]," — the PostgreSQL extension for vector search",[1236,8933,8934,8939],{},[1274,8935,8938],{"href":8936,"rel":8937},"https:\u002F\u002Fcore.telegram.org\u002Fbots\u002Fapi",[1304],"Telegram Bot API"," — receiving messages and long polling",[11,8941,8942],{},"Implementing RAG alongside an automated escalation policy lets the AI close routine tickets while humans join only the complex dialogues — cutting support costs many times over without sacrificing service quality.",[11,8944,8945,8946,2919,8948,2923],{},"If you want to implement a custom AI support chatbot, set up PostgreSQL Pgvector indexing, or build a complex CRM-integrated helpdesk, learn more about my ",[1274,8947,1324],{"href":1323},[1274,8949,2922],{"href":1328},[1332,8951,8952],{},"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);}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 .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}",{"title":40,"searchDepth":105,"depth":105,"links":8954},[8955,8956,8957,8958,8959,8960],{"id":7590,"depth":105,"text":7591},{"id":7600,"depth":105,"text":7601},{"id":7771,"depth":105,"text":7772},{"id":8189,"depth":105,"text":8190},{"id":8448,"depth":105,"text":8449},{"id":1293,"depth":105,"text":1294},"2026-06-04","A technical guide on engineering a professional customer support Telegram bot using OpenAI Embeddings, PostgreSQL Pgvector, and a hybrid AI-to-human escalation workflow.",[8964,8967,8970],{"q":8965,"a":8966},"How is a RAG bot different from 'just ChatGPT in a bot'?","A RAG bot answers strictly from your knowledge base: it first retrieves relevant documentation fragments via vector search, then passes them to the model as context. This removes the two main problems of a bare LLM — hallucinations and ignorance of your prices, guides, and API.",{"q":8968,"a":8969},"What happens when the bot doesn't know the answer?","The escalation protocol kicks in: if vector search finds no relevant context or the model returns an uncertainty marker, the session is flagged human_handling, managers get notified, and every following client message is forwarded to a live operator.",{"q":8971,"a":8972},"What does running such a bot cost?","Two cost lines: embedding generation when indexing the knowledge base (one-off and cheap) and LLM calls per question, billed by actual API usage. On a typical question flow this is well below a support operator's rate.","\u002Fimages\u002Fblog\u002Fblog_ai_rag_support.jpg",{},"\u002Fblog\u002Fen\u002Ftelegram-bot-ai-rag-support",{"title":7557,"description":8962},"blog\u002Fen\u002Ftelegram-bot-ai-rag-support",[1366,2958,8979,8980,1369,6867],"RAG","LLM","xenEUBpqI44ftTkRVApMiAkbHYr0IaaKjlrpTjNmWKk",{"id":8983,"title":8984,"body":8985,"date":9257,"description":9258,"extension":1348,"faq":9259,"image":9272,"lang":1360,"meta":9273,"navigation":108,"path":9275,"published":108,"readTime":154,"seo":9276,"stem":9277,"tags":9278,"updated":1346,"__hash__":9282},"blog\u002Fblog\u002Fen\u002Fbot-builder-vs-custom-development.md","Bot Builder vs Custom Development: How to Choose in 2026",{"type":8,"value":8986,"toc":9251},[8987,8990,9000,9004,9126,9130,9133,9159,9162,9166,9169,9207,9215,9219,9222,9233,9240],[11,8988,8989],{},"The short answer: if your scenario fits standard blocks — booking, payments, menus, broadcasts — take a no-code builder and launch in a day. If you need non-standard logic, deep integrations, or high load — order custom development from $250. Below is a decision table and where exactly the boundary runs.",[11,8991,8992,8993,8995,8996,8999],{},"I'm in an unusual position for this comparison: I'm both the author of the no-code builder ",[1274,8994,1277],{"href":1276}," and a developer of ",[1274,8997,8998],{"href":1323},"custom Telegram bots",". I have no incentive to oversell either option — here's how I choose myself.",[26,9001,9003],{"id":9002},"builder-vs-custom-the-decision-table","Builder vs custom: the decision table",[2422,9005,9006,9019],{},[2425,9007,9008],{},[2428,9009,9010,9013,9016],{},[2431,9011,9012],{"align":2433},"Criterion",[2431,9014,9015],{"align":2433},"No-code builder",[2431,9017,9018],{"align":2433},"Custom development",[2442,9020,9021,9034,9047,9061,9074,9087,9100,9113],{},[2428,9022,9023,9028,9031],{},[2447,9024,9025],{"align":2433},[15,9026,9027],{},"Launch time",[2447,9029,9030],{"align":2433},"Hours — 1 day",[2447,9032,9033],{"align":2433},"3–14 days",[2428,9035,9036,9041,9044],{},[2447,9037,9038],{"align":2433},[15,9039,9040],{},"Starting price",[2447,9042,9043],{"align":2433},"Subscription from a few $\u002Fmonth",[2447,9045,9046],{"align":2433},"From $250 one-time",[2428,9048,9049,9055,9058],{},[2447,9050,9051,9054],{"align":2433},[15,9052,9053],{},"Standard flows"," (booking, payments, FAQ)",[2447,9056,9057],{"align":2433},"✅ Ready-made blocks",[2447,9059,9060],{"align":2433},"✅ But more than you need to pay",[2428,9062,9063,9068,9071],{},[2447,9064,9065],{"align":2433},[15,9066,9067],{},"Non-standard logic",[2447,9069,9070],{"align":2433},"❌ You'll hit block limits",[2447,9072,9073],{"align":2433},"✅ Anything",[2428,9075,9076,9081,9084],{},[2447,9077,9078],{"align":2433},[15,9079,9080],{},"Integrations",[2447,9082,9083],{"align":2433},"Popular ones out of the box (CRM, sheets, payments)",[2447,9085,9086],{"align":2433},"Any, including in-house systems",[2428,9088,9089,9094,9097],{},[2447,9090,9091],{"align":2433},[15,9092,9093],{},"High load",[2447,9095,9096],{"align":2433},"Depends on the platform",[2447,9098,9099],{"align":2433},"✅ Architecture built for the task",[2428,9101,9102,9107,9110],{},[2447,9103,9104],{"align":2433},[15,9105,9106],{},"Edits without a developer",[2447,9108,9109],{"align":2433},"✅ Everything is yours to edit",[2447,9111,9112],{"align":2433},"✅ Content yourself, logic via a developer",[2428,9114,9115,9120,9123],{},[2447,9116,9117],{"align":2433},[15,9118,9119],{},"Ownership",[2447,9121,9122],{"align":2433},"The bot lives on the platform",[2447,9124,9125],{"align":2433},"Code and data are fully yours",[26,9127,9129],{"id":9128},"when-a-builder-is-enough","When a builder is enough",[11,9131,9132],{},"From my experience running TeleGo, standard blocks cover most small-business tasks:",[1296,9134,9135,9141,9147,9153],{},[1236,9136,9137,9140],{},[15,9138,9139],{},"Client booking"," — salons, studios, private practice;",[1236,9142,9143,9146],{},[15,9144,9145],{},"Selling access and courses"," — Stripe and Telegram Stars payments inside the chat;",[1236,9148,9149,9152],{},[15,9150,9151],{},"Lead collection and nurturing"," — surveys, segmentation, automated reminders;",[1236,9154,9155,9158],{},[15,9156,9157],{},"Support"," — FAQ answers, including AI replies from your own knowledge base.",[11,9160,9161],{},"If your task is on this list, don't overpay for development: assemble the bot in a visual editor, validate demand, and only then decide whether you need custom. TeleGo has a free 7-day Pro trial exactly for that.",[26,9163,9165],{"id":9164},"when-you-need-custom","When you need custom",[11,9167,9168],{},"Custom development is justified when at least one of these appears:",[1233,9170,9171,9180,9186,9195,9201],{},[1236,9172,9173,9176,9177,62],{},[15,9174,9175],{},"Non-standard business logic"," — lead scoring, complex branching, calculations. A code-level example is in my article on ",[1274,9178,9179],{"href":8907},"integrating a bot with a CRM",[1236,9181,9182,9185],{},[15,9183,9184],{},"Deep integrations"," — an in-house CRM, ERP, niche APIs that no builder supports.",[1236,9187,9188,9191,9192,443],{},[15,9189,9190],{},"AI on your data"," — a RAG bot that answers strictly from your knowledge base and hands complex dialogues to a human (",[1274,9193,9194],{"href":1281},"architecture breakdown",[1236,9196,9197,9200],{},[15,9198,9199],{},"Load"," — tens of thousands of users require purpose-built queues and database architecture.",[1236,9202,9203,9206],{},[15,9204,9205],{},"Code ownership requirements"," — corporate policies or investors.",[11,9208,9209,9210,9214],{},"What it costs by bot type is in my ",[1274,9211,9213],{"href":9212},"\u002Fblog\u002Ftelegram-bot-development-cost","detailed development cost breakdown","; the short version: a simple bot from $250, a business bot from $500, an AI bot from $900 — all with a control panel and 6 months of hosting included.",[26,9216,9218],{"id":9217},"the-hybrid-path-how-i-do-it","The hybrid path: how I do it",[11,9220,9221],{},"The most common mistake is choosing \"forever.\" The working scheme looks different:",[1233,9223,9224,9227,9230],{},[1236,9225,9226],{},"Assemble an MVP bot on a builder in a day and launch it on real clients.",[1236,9228,9229],{},"Collect data: which flows get used, where users drop off.",[1236,9231,9232],{},"When you hit the ceiling — order custom, porting flows already proven by demand, not hypotheses.",[11,9234,9235,9236,9239],{},"This way you pay for development once, and only for logic validated by real usage. Technically the bot talks to the ",[1274,9237,8938],{"href":8936,"rel":9238},[1304]," in both cases — migration means porting flows and the client database, not starting over.",[11,9241,9242,9243,9246,9247,9250],{},"Not sure which path is yours? Describe the task on the ",[1274,9244,9245],{"href":1323},"Telegram bot development page"," — I'll tell you honestly if a builder is enough, or book a ",[1274,9248,9249],{"href":1328},"consultation"," for a scenario review.",{"title":40,"searchDepth":105,"depth":105,"links":9252},[9253,9254,9255,9256],{"id":9002,"depth":105,"text":9003},{"id":9128,"depth":105,"text":9129},{"id":9164,"depth":105,"text":9165},{"id":9217,"depth":105,"text":9218},"2026-05-31","An honest comparison of no-code Telegram bot builders and custom development — price, timelines, flexibility, and the capability ceiling. Decision table inside.",[9260,9263,9266,9269],{"q":9261,"a":9262},"When is a bot builder enough?","When your scenario fits standard blocks: welcome flow, menu, booking, payments, broadcasts, FAQ answers. That covers 80% of small-business tasks — salons, schools, consultants, e-commerce.",{"q":9264,"a":9265},"Can I migrate from a builder to custom development later?","Yes, and it's a normal path: validate demand on a builder within a day, and when you hit its limits, order a custom bot and port the proven flows. Just make sure you can export your client database.",{"q":9267,"a":9268},"Which is cheaper over a year?","For standard scenarios — a builder: the subscription costs less than maintaining custom code. For non-standard logic — custom: workarounds fighting a builder's limits cost more than development.",{"q":9270,"a":9271},"Who maintains the bot after launch?","On a builder — you do, through the visual editor. With custom development — you edit content through a control panel, while a developer maintains the logic and integrations.","\u002Fimages\u002Fblog\u002Fblog_bot_builder_vs_custom.jpg",{"robots":9274},"noindex, nofollow","\u002Fblog\u002Fen\u002Fbot-builder-vs-custom-development",{"title":8984,"description":9258},"blog\u002Fen\u002Fbot-builder-vs-custom-development",[1366,9279,9280,9281],"Bots","No-code","Business","JukMQx7pxUi1p2Y-LTfcJzn73-ViNcCuwzFwvk6WD5w",{"id":9284,"title":9285,"body":9286,"date":9534,"description":9535,"extension":1348,"faq":9536,"image":9549,"lang":1360,"meta":9550,"navigation":108,"path":9551,"published":108,"readTime":167,"seo":9552,"stem":9553,"tags":9554,"updated":1346,"__hash__":9555},"blog\u002Fblog\u002Fen\u002Ftelegram-bot-development-cost.md","How Much Does Telegram Bot Development Cost in 2026: A Real Price Breakdown",{"type":8,"value":9287,"toc":9527},[9288,9291,9295,9303,9327,9331,9334,9402,9405,9409,9412,9437,9441,9444,9480,9483,9487,9511,9518],[11,9289,9290],{},"Turnkey Telegram bot development costs from $250 for a simple menu-and-leads bot to $900+ for a smart AI bot integrated with your CRM. The final price depends on three things: dialogue complexity, the number of integrations, and load requirements. This article is an honest cost breakdown by bot type — what the price includes and which expenses appear after launch.",[26,9292,9294],{"id":9293},"what-drives-the-cost-of-a-telegram-bot","What drives the cost of a Telegram bot?",[11,9296,9297,9298,9302],{},"You are paying for the bot's logic, not for \"code that sends messages.\" The Telegram Bot API itself is free and well documented (",[1274,9299,9301],{"href":8936,"rel":9300},[1304],"official documentation","), so the budget goes to:",[1233,9304,9305,9311,9316,9322],{},[1236,9306,9307,9310],{},[15,9308,9309],{},"Flows"," — how many dialogue branches, states, and edge cases need to be designed and tested.",[1236,9312,9313,9315],{},[15,9314,9080],{}," — payments (Stripe, Telegram Stars, crypto), CRMs (HubSpot, amoCRM), Google Sheets and Calendar, AI models.",[1236,9317,9318,9321],{},[15,9319,9320],{},"Control panel"," — whether the owner can edit content independently, or every small change requires a developer.",[1236,9323,9324,9326],{},[15,9325,9199],{}," — a bot for a salon with 200 clients and a bot for an online school with 20,000 students are built differently.",[26,9328,9330],{"id":9329},"prices-by-bot-type","Prices by bot type",[11,9332,9333],{},"A breakdown from my real practice — starting prices for a typical scope:",[2422,9335,9336,9352],{},[2425,9337,9338],{},[2428,9339,9340,9343,9346,9349],{},[2431,9341,9342],{"align":2433},"Bot type",[2431,9344,9345],{"align":2433},"What it does",[2431,9347,9348],{"align":2433},"Timeline",[2431,9350,9351],{"align":2433},"Price",[2442,9353,9354,9370,9386],{},[2428,9355,9356,9361,9364,9367],{},[2447,9357,9358],{"align":2433},[15,9359,9360],{},"Simple bot",[2447,9362,9363],{"align":2433},"Welcome menu, button navigation, contact collection, lead database",[2447,9365,9366],{"align":2433},"3–5 days",[2447,9368,9369],{"align":2433},"from $250",[2428,9371,9372,9377,9380,9383],{},[2447,9373,9374],{"align":2433},[15,9375,9376],{},"Business bot",[2447,9378,9379],{"align":2433},"Everything above + booking or payments, surveys, automated reminders, client segmentation",[2447,9381,9382],{"align":2433},"7–10 days",[2447,9384,9385],{"align":2433},"from $500",[2428,9387,9388,9393,9396,9399],{},[2447,9389,9390],{"align":2433},[15,9391,9392],{},"Smart AI bot",[2447,9394,9395],{"align":2433},"Everything above + auto-answers from your knowledge base, CRM and external integrations, human handover",[2447,9397,9398],{"align":2433},"10–14 days",[2447,9400,9401],{"align":2433},"from $900",[11,9403,9404],{},"Every package includes: bot configuration, a no-code control panel, testing, launch, and the first 6 months of hosting.",[26,9406,9408],{"id":9407},"which-costs-appear-after-launch","Which costs appear after launch?",[11,9410,9411],{},"The honest answer that rarely makes it into price lists:",[1296,9413,9414,9420,9426,9431],{},[1236,9415,9416,9419],{},[15,9417,9418],{},"Hosting"," — after the included period, a simple bot lives on a small VPS for a few dollars a month.",[1236,9421,9422,9425],{},[15,9423,9424],{},"LLM API usage"," — AI bots generate answers via OpenAI, Claude, or Gemini, billed by actual usage. For a typical support bot this is far below an operator's salary, but the line item should be visible upfront.",[1236,9427,9428,9430],{},[15,9429,3740],{}," — Stripe and Telegram Stars take a percentage per transaction; that's a cost of accepting payments, not of the bot.",[1236,9432,9433,9436],{},[15,9434,9435],{},"New features"," — additional flow branches and integrations. This is where the control panel pays off: text and button changes you make yourself, for free.",[26,9438,9440],{"id":9439},"when-does-a-bot-pay-for-itself","When does a bot pay for itself?",[11,9442,9443],{},"Channel economics is the main reason businesses order bots instead of yet another landing page:",[1296,9445,9446,9457,9468],{},[1236,9447,9448,9449,357,9452,3704],{},"Telegram has over ",[15,9450,9451],{},"1 billion monthly active users",[1274,9453,9456],{"href":9454,"rel":9455},"https:\u002F\u002Ftechcrunch.com\u002F2025\u002F03\u002F19\u002Ftelegram-founder-pavel-durov-says-app-now-has-1b-users-calls-whatsapp-a-cheap-watered-down-imitation\u002F",[1304],"TechCrunch, March 2025",[1236,9458,9459,9460,357,9463,3704],{},"messenger open rates reach ",[15,9461,9462],{},"80% versus ~20% for email",[1274,9464,9467],{"href":9465,"rel":9466},"https:\u002F\u002Fwww.levelingup.com\u002Fgrowth-everywhere-interview\u002Fmikael-yang-manychat\u002F",[1304],"ManyChat CEO interview",[1236,9469,9470,9471,9474,9475,443],{},"cost per lead through a bot can be ",[15,9472,9473],{},"up to 7x lower"," than through a classic landing page (",[1274,9476,9479],{"href":9477,"rel":9478},"https:\u002F\u002Fblog.chatfuel.com\u002Fchatbots-vs-landing-pages\u002F",[1304],"Chatfuel data",[11,9481,9482],{},"A practical benchmark: if a bot saves at least one hour of an administrator's manual work per day, or brings a few qualified leads per week, a $250–900 investment returns within the first months.",[26,9484,9486],{"id":9485},"how-to-save-money-without-cutting-quality","How to save money without cutting quality",[1296,9488,9489,9495,9505],{},[1236,9490,9491,9494],{},[15,9492,9493],{},"Start with a simple bot"," and add AI and integrations after demand is proven — my bot architecture allows growing features without a rewrite.",[1236,9496,9497,9500,9501,62],{},[15,9498,9499],{},"Prepare the scenario in advance",": dialogues described in your own words and a list of real customer questions shorten the design phase. What such a brief should contain is covered in my ",[1274,9502,9504],{"href":9503},"\u002Fblog\u002Ftelegram-bot-specification-checklist","bot specification checklist",[1236,9506,9507,9510],{},[15,9508,9509],{},"Don't order features \"for later\"",": broadcasts, referral programs, and multi-language support are easy to add as a second phase.",[11,9512,9513,9514,62],{},"Choosing between custom development and a no-code builder? I covered that decision in ",[1274,9515,9517],{"href":9516},"\u002Fblog\u002Fbot-builder-vs-custom-development","Bot builder vs custom development",[11,9519,9520,9521,9523,9524,9526],{},"Want an exact estimate for your task? Describe it on the ",[1274,9522,9245],{"href":1323}," — I reply within 24 hours with a timeline and budget, or book a ",[1274,9525,9249],{"href":1328}," if the task isn't shaped into a spec yet.",{"title":40,"searchDepth":105,"depth":105,"links":9528},[9529,9530,9531,9532,9533],{"id":9293,"depth":105,"text":9294},{"id":9329,"depth":105,"text":9330},{"id":9407,"depth":105,"text":9408},{"id":9439,"depth":105,"text":9440},{"id":9485,"depth":105,"text":9486},"2026-05-28","Real Telegram bot development prices — from a $250 menu bot to an AI bot with CRM. What's included, hidden costs after launch, and when a bot pays off.",[9537,9540,9543,9546],{"q":9538,"a":9539},"How long does it take to build a Telegram bot?","A simple bot with a menu and lead collection ships in 3–5 days. A business bot with booking and payments takes 7–10 days. A smart AI bot with knowledge-base answers and CRM integration takes 10–14 days. Timelines count from an agreed specification to launch.",{"q":9541,"a":9542},"Do I pay for bot hosting separately?","My packages include the first 6 months of hosting in the development price. After that, a simple bot runs on a small VPS for a few dollars a month; AI bots add language-model API usage billed by actual consumption.",{"q":9544,"a":9545},"Can I edit bot texts and flows without a developer?","Yes. Every bot ships with a no-code control panel: texts, buttons, flows, and broadcasts are edited visually. A developer is only needed for new integrations or non-standard logic.",{"q":9547,"a":9548},"What makes the price go above the base package?","Three main factors: non-standard integrations (in-house CRMs, niche payment providers), complex branching dialogue logic, and load requirements — a bot serving tens of thousands of users needs a different queue and database architecture.","\u002Fimages\u002Fblog\u002Fblog_telegram_bot_cost.jpg",{},"\u002Fblog\u002Fen\u002Ftelegram-bot-development-cost",{"title":9285,"description":9535},"blog\u002Fen\u002Ftelegram-bot-development-cost",[1366,9279,3837,9281],"M2oi5vdy9eecXXtgUJIsc0qMpn2xD4kusQrZCGB_zwk",{"id":9557,"title":9558,"body":9559,"date":9801,"description":9802,"extension":1348,"faq":9803,"image":9816,"lang":1360,"meta":9817,"navigation":108,"path":9818,"published":108,"readTime":154,"seo":9819,"stem":9820,"tags":9821,"updated":1346,"__hash__":9824},"blog\u002Fblog\u002Fen\u002Ftelegram-bot-specification-checklist.md","What a Telegram Bot Specification Should Include: A Checklist with Examples",{"type":8,"value":9560,"toc":9792},[9561,9564,9568,9571,9585,9588,9592,9595,9601,9604,9608,9611,9648,9652,9655,9704,9710,9714,9737,9741,9744,9773,9777,9784],[11,9562,9563],{},"A good task description cuts both the estimate and the timeline of Telegram bot development: the less a developer has to guess, the less risk padding lands in the quote. Below is the 6-block checklist I ask clients to fill in before estimating a project. It also filters out the most common mistake — ordering \"a bot in general\" instead of a bot for a specific process.",[26,9565,9567],{"id":9566},"_1-the-goal-which-metric-does-the-bot-change","1. The goal: which metric does the bot change?",[11,9569,9570],{},"One paragraph, without which everything else is pointless:",[1296,9572,9573,9579],{},[1236,9574,9575,9578],{},[2414,9576,9577],{},"Bad",": \"We need a bot for our salon.\"",[1236,9580,9581,9584],{},[2414,9582,9583],{},"Good",": \"Clients book by phone; the receptionist spends 3 hours a day on it. The bot should handle 70% of bookings without a human.\"",[11,9586,9587],{},"The goal drives both the feature priorities and how we'll know the bot paid off.",[26,9589,9591],{"id":9590},"_2-dialogue-scenarios-step-by-step","2. Dialogue scenarios, step by step",[11,9593,9594],{},"Describe 3–5 main scenarios the way a real client would walk through them:",[34,9596,9599],{"className":9597,"code":9598,"language":39,"meta":40},[37],"Scenario \"Book a service\":\n1. Client taps \u002Fstart → greeting + menu [Book] [Prices] [Question]\n2. [Book] → bot shows the service list (buttons)\n3. Service chosen → bot shows available slots for the week\n4. Slot chosen → bot asks for a phone number (\"Share contact\" button)\n5. Confirmation → calendar entry + notification to the administrator\n6. One day before the visit → automatic reminder to the client\n",[42,9600,9598],{"__ignoreMap":40},[11,9602,9603],{},"This format answers most design questions by itself: which buttons, what data we collect, who gets notified.",[26,9605,9607],{"id":9606},"_3-edge-cases-and-errors","3. Edge cases and errors",[11,9609,9610],{},"The block missing from 9 out of 10 briefs — and the one that defines bot quality:",[1296,9612,9613,9619,9626,9636,9642],{},[1236,9614,9615,9616,791],{},"What does the client see when ",[15,9617,9618],{},"all slots are taken",[1236,9620,9621,9622,9625],{},"What happens if the client ",[15,9623,9624],{},"abandons the dialogue midway"," and returns a day later?",[1236,9627,9628,9629,9632,9633,291],{},"Who answers when the client asks a ",[15,9630,9631],{},"question outside the flow","? (the AI-answers-from-knowledge-base option is covered in my ",[1274,9634,9635],{"href":1281},"RAG support bot breakdown",[1236,9637,9638,9639,791],{},"How does the client ",[15,9640,9641],{},"cancel or reschedule",[1236,9643,9644,9645,791],{},"What does the bot do on a ",[15,9646,9647],{},"failed payment",[26,9649,9651],{"id":9650},"_4-integrations-and-data","4. Integrations and data",[11,9653,9654],{},"List the systems the bot must exchange data with, and in which direction:",[2422,9656,9657,9670],{},[2425,9658,9659],{},[2428,9660,9661,9664,9667],{},[2431,9662,9663],{"align":2433},"System",[2431,9665,9666],{"align":2433},"What we send",[2431,9668,9669],{"align":2433},"What we receive",[2442,9671,9672,9683,9693],{},[2428,9673,9674,9677,9680],{},[2447,9675,9676],{"align":2433},"CRM (HubSpot, amoCRM…)",[2447,9678,9679],{"align":2433},"Contact, deal, survey answers",[2447,9681,9682],{"align":2433},"Statuses, client history",[2428,9684,9685,9688,9690],{},[2447,9686,9687],{"align":2433},"Payments (Stripe, Telegram Stars)",[2447,9689,5443],{"align":2433},[2447,9691,9692],{"align":2433},"Transaction status",[2428,9694,9695,9698,9701],{},[2447,9696,9697],{"align":2433},"Google Calendar \u002F Sheets",[2447,9699,9700],{"align":2433},"Bookings, leads",[2447,9702,9703],{"align":2433},"Available slots",[11,9705,9706,9707,62],{},"If an integration is in-house (your own CRM or ERP), attach the API description or a contact who owns it. How a bot-to-CRM pipeline works technically is in my ",[1274,9708,9709],{"href":8907},"hands-on guide with code",[26,9711,9713],{"id":9712},"_5-roles-and-permissions","5. Roles and permissions",[1296,9715,9716,9723,9730],{},[1236,9717,9718,9719,9722],{},"Who ",[15,9720,9721],{},"administers"," the bot (edits texts, views leads)?",[1236,9724,9725,9726,9729],{},"Who receives ",[15,9727,9728],{},"notifications"," about new leads and payments?",[1236,9731,9732,9733,9736],{},"Is ",[15,9734,9735],{},"human handover"," needed, and who is that human during\u002Foutside business hours?",[26,9738,9740],{"id":9739},"_6-non-functional-requirements","6. Non-functional requirements",[11,9742,9743],{},"Short, but explicit:",[1296,9745,9746,9751,9757,9763],{},[1236,9747,9748,9750],{},[15,9749,9199],{},": how many users per day do you expect (10? 1,000? 10,000?) — the architecture depends on it.",[1236,9752,9753,9756],{},[15,9754,9755],{},"Languages",": one or several.",[1236,9758,9759,9762],{},[15,9760,9761],{},"Data",": where personal client data lives, whether exports are needed.",[1236,9764,9765,9768,9769,9772],{},[15,9766,9767],{},"Platform limits",": the ",[1274,9770,8938],{"href":8936,"rel":9771},[1304]," rate-limits message sending — mass broadcasts are designed around those limits.",[26,9774,9776],{"id":9775},"what-happens-to-this-brief-next","What happens to this brief next",[11,9778,9779,9780,9783],{},"The completed checklist is one or two pages of text. From it I prepare a specification with milestones and a fixed estimate (price benchmarks are in ",[1274,9781,9782],{"href":9212},"how much a Telegram bot costs","), we approve it, and development runs with visible results every 3–5 days.",[11,9785,9786,9787,9789,9790,62],{},"Ready to describe your task? Send the brief via the ",[1274,9788,9245],{"href":1323}," — I'll reply with an estimate within 24 hours. If the scenarios haven't taken shape yet, let's work through them together in a ",[1274,9791,9249],{"href":1328},{"title":40,"searchDepth":105,"depth":105,"links":9793},[9794,9795,9796,9797,9798,9799,9800],{"id":9566,"depth":105,"text":9567},{"id":9590,"depth":105,"text":9591},{"id":9606,"depth":105,"text":9607},{"id":9650,"depth":105,"text":9651},{"id":9712,"depth":105,"text":9713},{"id":9739,"depth":105,"text":9740},{"id":9775,"depth":105,"text":9776},"2026-05-24","A Telegram bot spec checklist — dialogue flows, states, error cases, integrations, and non-functional requirements. Cuts both the estimate and the timeline.",[9804,9807,9810,9813],{"q":9805,"a":9806},"Do I need a formal specification, or can I describe the task in my own words?","Your own words are fine. A good brief is dialogue scenarios and a list of real customer questions, not a formal document. Turning it into a specification is my job at the briefing stage.",{"q":9808,"a":9809},"Who writes the final spec — the client or the developer?","The developer, based on your brief. You describe the business task and scenarios; I turn them into a specification with states, integrations, and staged milestones — and we approve it before development starts.",{"q":9811,"a":9812},"How detailed should the description be?","One or two pages is enough: the bot's goal, 3–5 step-by-step dialogue scenarios, a list of integrations, and examples of real customer questions. Detail beyond that rarely speeds up the project.",{"q":9814,"a":9815},"What if requirements change mid-project?","That's normal and expected. I show intermediate results every 3–5 days, so adjustments happen along the way, and larger scope changes become a separate stage so the estimate stays transparent.","\u002Fimages\u002Fblog\u002Fblog_bot_spec_checklist.jpg",{},"\u002Fblog\u002Fen\u002Ftelegram-bot-specification-checklist",{"title":9558,"description":9802},"blog\u002Fen\u002Ftelegram-bot-specification-checklist",[1366,9279,9822,9823],"Specification","Process","PGkmusMNrT6h56320OIKPgrOSFlcEUwK7Up_xZv7pew",{"id":9826,"title":9827,"body":9828,"date":11167,"description":11168,"extension":1348,"faq":11169,"image":11179,"lang":1360,"meta":11180,"navigation":108,"path":11181,"published":108,"readTime":215,"seo":11182,"stem":11183,"tags":11184,"updated":1346,"__hash__":11187},"blog\u002Fblog\u002Fen\u002Ftelegram-bot-crm-integration.md","Why Your CRM Needs a Custom Telegram Bot (HubSpot & AmoCRM Integration)",{"type":8,"value":9829,"toc":11160},[9830,9833,9836,9839,9842,9846,9852,9856,9859,10305,10309,10315,10946,10950,10957,11110,11112,11136,11139,11149,11157],[11,9831,9832],{},"For many modern businesses, messaging apps are the highest-converting customer communication channels. However, managing leads manually in chats is highly inefficient. Sales reps miss messages, data is left unrecorded in spreadsheet files, and lead qualification takes hours of manual back-and-forth.",[11,9834,9835],{},"Integrating a custom Telegram bot with your CRM (such as HubSpot, AmoCRM, or Bitrix24) automates the top of your sales funnel.",[11,9837,9838],{},"A custom bot greets potential clients, asks targeted questions using inline menus, performs basic lead scoring, and instantly pushes structured data into your sales pipeline.",[11,9840,9841],{},"In this technical guide, we will design a qualification workflow, write a lead-scoring algorithm, and implement a complete API synchronization script using Node.js.",[26,9843,9845],{"id":9844},"technical-lead-qualification-pipeline","Technical Lead Qualification Pipeline",[34,9847,9850],{"className":9848,"code":9849,"language":39,"meta":40},[37],"[User starts bot]\n        │\n        v\n[Qualify Question 1: Budget?] ──> [Inline buttons: $1k, $5k, $10k+]\n        │\n        v\n[Qualify Question 2: Stack?] ──> [Inline buttons: Vue\u002FNuxt, React, Python]\n        │\n        v\n[Input Contact] ──> [Telegram sharing buttons (Phone) or Text (Email)]\n        │\n        v\n(Run Lead Scoring Algorithm)\nIF budget = \"$10k+\" THEN Priority = \"HIGH\" ELSE Priority = \"MEDIUM\"\n        │\n        v\n(Call HubSpot API via SDK)\nCreate CRM Contact ──> Create CRM Deal ──> Associate Deal with Contact\n        │\n        v\n[Notify Sales Rep on Slack\u002FTelegram with direct Link to CRM]\n",[42,9851,9849],{"__ignoreMap":40},[26,9853,9855],{"id":9854},"_1-defining-the-lead-scoring-algorithm","1. Defining the Lead Scoring Algorithm",[11,9857,9858],{},"Before pushing data to your CRM, calculate a lead score based on user choices. This helps your sales team prioritize high-value projects.",[34,9860,9862],{"className":75,"code":9861,"language":77,"meta":40,"style":40},"export interface QualificationAnswers {\n  budget: string; \u002F\u002F 'under_2k' | '2k_10k' | 'over_10k'\n  timeline: string; \u002F\u002F 'immediate' | '1_month' | 'planning'\n  projectType: string; \u002F\u002F 'saas' | 'bot' | 'consultation'\n}\n\nexport interface LeadScore {\n  score: number;\n  priority: 'LOW' | 'MEDIUM' | 'HIGH';\n}\n\nexport function calculateLeadScore(answers: QualificationAnswers): LeadScore {\n  let score = 0;\n\n  \u002F\u002F 1. Evaluate Budget\n  if (answers.budget === 'over_10k') score += 50;\n  else if (answers.budget === '2k_10k') score += 30;\n  else score += 10;\n\n  \u002F\u002F 2. Evaluate Timeline urgency\n  if (answers.timeline === 'immediate') score += 30;\n  else if (answers.timeline === '1_month') score += 20;\n  else score += 5;\n\n  \u002F\u002F 3. Evaluate Project Alignment\n  if (answers.projectType === 'saas') score += 20; \u002F\u002F High alignment\n  else if (answers.projectType === 'bot') score += 15;\n  else score += 5;\n\n  let priority: 'LOW' | 'MEDIUM' | 'HIGH' = 'LOW';\n  if (score >= 70) priority = 'HIGH';\n  else if (score >= 40) priority = 'MEDIUM';\n\n  return { score, priority };\n}\n",[42,9863,9864,9875,9890,9904,9918,9922,9926,9937,9948,9971,9975,9979,10005,10019,10023,10028,10051,10075,10088,10092,10097,10117,10139,10152,10156,10161,10184,10206,10218,10222,10247,10269,10290,10294,10301],{"__ignoreMap":40},[81,9865,9866,9868,9870,9873],{"class":83,"line":84},[81,9867,262],{"class":87},[81,9869,4116],{"class":87},[81,9871,9872],{"class":118}," QualificationAnswers",[81,9874,122],{"class":91},[81,9876,9877,9880,9882,9884,9887],{"class":83,"line":105},[81,9878,9879],{"class":128},"  budget",[81,9881,132],{"class":87},[81,9883,149],{"class":135},[81,9885,9886],{"class":91},"; ",[81,9888,9889],{"class":395},"\u002F\u002F 'under_2k' | '2k_10k' | 'over_10k'\n",[81,9891,9892,9895,9897,9899,9901],{"class":83,"line":112},[81,9893,9894],{"class":128},"  timeline",[81,9896,132],{"class":87},[81,9898,149],{"class":135},[81,9900,9886],{"class":91},[81,9902,9903],{"class":395},"\u002F\u002F 'immediate' | '1_month' | 'planning'\n",[81,9905,9906,9909,9911,9913,9915],{"class":83,"line":125},[81,9907,9908],{"class":128},"  projectType",[81,9910,132],{"class":87},[81,9912,149],{"class":135},[81,9914,9886],{"class":91},[81,9916,9917],{"class":395},"\u002F\u002F 'saas' | 'bot' | 'consultation'\n",[81,9919,9920],{"class":83,"line":141},[81,9921,207],{"class":91},[81,9923,9924],{"class":83,"line":154},[81,9925,109],{"emptyLinePlaceholder":108},[81,9927,9928,9930,9932,9935],{"class":83,"line":167},[81,9929,262],{"class":87},[81,9931,4116],{"class":87},[81,9933,9934],{"class":118}," LeadScore",[81,9936,122],{"class":91},[81,9938,9939,9942,9944,9946],{"class":83,"line":179},[81,9940,9941],{"class":128},"  score",[81,9943,132],{"class":87},[81,9945,136],{"class":135},[81,9947,102],{"class":91},[81,9949,9950,9953,9955,9958,9961,9964,9966,9969],{"class":83,"line":191},[81,9951,9952],{"class":128},"  priority",[81,9954,132],{"class":87},[81,9956,9957],{"class":98}," 'LOW'",[81,9959,9960],{"class":87}," |",[81,9962,9963],{"class":98}," 'MEDIUM'",[81,9965,9960],{"class":87},[81,9967,9968],{"class":98}," 'HIGH'",[81,9970,102],{"class":91},[81,9972,9973],{"class":83,"line":204},[81,9974,207],{"class":91},[81,9976,9977],{"class":83,"line":210},[81,9978,109],{"emptyLinePlaceholder":108},[81,9980,9981,9983,9985,9988,9990,9993,9995,9997,9999,10001,10003],{"class":83,"line":215},[81,9982,262],{"class":87},[81,9984,265],{"class":87},[81,9986,9987],{"class":118}," calculateLeadScore",[81,9989,271],{"class":91},[81,9991,9992],{"class":128},"answers",[81,9994,132],{"class":87},[81,9996,9872],{"class":118},[81,9998,291],{"class":91},[81,10000,132],{"class":87},[81,10002,9934],{"class":118},[81,10004,122],{"class":91},[81,10006,10007,10010,10013,10015,10017],{"class":83,"line":225},[81,10008,10009],{"class":87},"  let",[81,10011,10012],{"class":91}," score ",[81,10014,1093],{"class":87},[81,10016,8104],{"class":135},[81,10018,102],{"class":91},[81,10020,10021],{"class":83,"line":237},[81,10022,109],{"emptyLinePlaceholder":108},[81,10024,10025],{"class":83,"line":249},[81,10026,10027],{"class":395},"  \u002F\u002F 1. Evaluate Budget\n",[81,10029,10030,10032,10035,10037,10040,10043,10046,10049],{"class":83,"line":254},[81,10031,354],{"class":87},[81,10033,10034],{"class":91}," (answers.budget ",[81,10036,694],{"class":87},[81,10038,10039],{"class":98}," 'over_10k'",[81,10041,10042],{"class":91},") score ",[81,10044,10045],{"class":87},"+=",[81,10047,10048],{"class":135}," 50",[81,10050,102],{"class":91},[81,10052,10053,10056,10059,10061,10063,10066,10068,10070,10073],{"class":83,"line":259},[81,10054,10055],{"class":87},"  else",[81,10057,10058],{"class":87}," if",[81,10060,10034],{"class":91},[81,10062,694],{"class":87},[81,10064,10065],{"class":98}," '2k_10k'",[81,10067,10042],{"class":91},[81,10069,10045],{"class":87},[81,10071,10072],{"class":135}," 30",[81,10074,102],{"class":91},[81,10076,10077,10079,10081,10083,10086],{"class":83,"line":300},[81,10078,10055],{"class":87},[81,10080,10012],{"class":91},[81,10082,10045],{"class":87},[81,10084,10085],{"class":135}," 10",[81,10087,102],{"class":91},[81,10089,10090],{"class":83,"line":321},[81,10091,109],{"emptyLinePlaceholder":108},[81,10093,10094],{"class":83,"line":345},[81,10095,10096],{"class":395},"  \u002F\u002F 2. Evaluate Timeline urgency\n",[81,10098,10099,10101,10104,10106,10109,10111,10113,10115],{"class":83,"line":351},[81,10100,354],{"class":87},[81,10102,10103],{"class":91}," (answers.timeline ",[81,10105,694],{"class":87},[81,10107,10108],{"class":98}," 'immediate'",[81,10110,10042],{"class":91},[81,10112,10045],{"class":87},[81,10114,10072],{"class":135},[81,10116,102],{"class":91},[81,10118,10119,10121,10123,10125,10127,10130,10132,10134,10137],{"class":83,"line":366},[81,10120,10055],{"class":87},[81,10122,10058],{"class":87},[81,10124,10103],{"class":91},[81,10126,694],{"class":87},[81,10128,10129],{"class":98}," '1_month'",[81,10131,10042],{"class":91},[81,10133,10045],{"class":87},[81,10135,10136],{"class":135}," 20",[81,10138,102],{"class":91},[81,10140,10141,10143,10145,10147,10150],{"class":83,"line":381},[81,10142,10055],{"class":87},[81,10144,10012],{"class":91},[81,10146,10045],{"class":87},[81,10148,10149],{"class":135}," 5",[81,10151,102],{"class":91},[81,10153,10154],{"class":83,"line":387},[81,10155,109],{"emptyLinePlaceholder":108},[81,10157,10158],{"class":83,"line":392},[81,10159,10160],{"class":395},"  \u002F\u002F 3. Evaluate Project Alignment\n",[81,10162,10163,10165,10168,10170,10173,10175,10177,10179,10181],{"class":83,"line":399},[81,10164,354],{"class":87},[81,10166,10167],{"class":91}," (answers.projectType ",[81,10169,694],{"class":87},[81,10171,10172],{"class":98}," 'saas'",[81,10174,10042],{"class":91},[81,10176,10045],{"class":87},[81,10178,10136],{"class":135},[81,10180,9886],{"class":91},[81,10182,10183],{"class":395},"\u002F\u002F High alignment\n",[81,10185,10186,10188,10190,10192,10194,10197,10199,10201,10204],{"class":83,"line":452},[81,10187,10055],{"class":87},[81,10189,10058],{"class":87},[81,10191,10167],{"class":91},[81,10193,694],{"class":87},[81,10195,10196],{"class":98}," 'bot'",[81,10198,10042],{"class":91},[81,10200,10045],{"class":87},[81,10202,10203],{"class":135}," 15",[81,10205,102],{"class":91},[81,10207,10208,10210,10212,10214,10216],{"class":83,"line":457},[81,10209,10055],{"class":87},[81,10211,10012],{"class":91},[81,10213,10045],{"class":87},[81,10215,10149],{"class":135},[81,10217,102],{"class":91},[81,10219,10220],{"class":83,"line":463},[81,10221,109],{"emptyLinePlaceholder":108},[81,10223,10224,10226,10229,10231,10233,10235,10237,10239,10241,10243,10245],{"class":83,"line":476},[81,10225,10009],{"class":87},[81,10227,10228],{"class":91}," priority",[81,10230,132],{"class":87},[81,10232,9957],{"class":98},[81,10234,9960],{"class":87},[81,10236,9963],{"class":98},[81,10238,9960],{"class":87},[81,10240,9968],{"class":98},[81,10242,309],{"class":87},[81,10244,9957],{"class":98},[81,10246,102],{"class":91},[81,10248,10249,10251,10254,10257,10260,10263,10265,10267],{"class":83,"line":518},[81,10250,354],{"class":87},[81,10252,10253],{"class":91}," (score ",[81,10255,10256],{"class":87},">=",[81,10258,10259],{"class":135}," 70",[81,10261,10262],{"class":91},") priority ",[81,10264,1093],{"class":87},[81,10266,9968],{"class":98},[81,10268,102],{"class":91},[81,10270,10271,10273,10275,10277,10279,10282,10284,10286,10288],{"class":83,"line":538},[81,10272,10055],{"class":87},[81,10274,10058],{"class":87},[81,10276,10253],{"class":91},[81,10278,10256],{"class":87},[81,10280,10281],{"class":135}," 40",[81,10283,10262],{"class":91},[81,10285,1093],{"class":87},[81,10287,9963],{"class":98},[81,10289,102],{"class":91},[81,10291,10292],{"class":83,"line":543},[81,10293,109],{"emptyLinePlaceholder":108},[81,10295,10296,10298],{"class":83,"line":549},[81,10297,1207],{"class":87},[81,10299,10300],{"class":91}," { score, priority };\n",[81,10302,10303],{"class":83,"line":555},[81,10304,207],{"class":91},[26,10306,10308],{"id":10307},"_2-syncing-leads-with-hubspot-crm-api","2. Syncing Leads with HubSpot CRM API",[11,10310,10311,10312,62],{},"Here is a robust implementation using TypeScript and Axios to create a verified contact and a prioritized deal in ",[15,10313,10314],{},"HubSpot",[34,10316,10318],{"className":75,"code":10317,"language":77,"meta":40,"style":40},"import axios from 'axios';\n\ninterface LeadPayload {\n  firstName: string;\n  lastName?: string;\n  phone: string;\n  telegramUsername?: string;\n  score: LeadScore;\n  answers: QualificationAnswers;\n}\n\nexport async function createHubSpotLead(lead: LeadPayload) {\n  const token = process.env.HUBSPOT_ACCESS_TOKEN;\n  const headers = {\n    Authorization: `Bearer ${token}`,\n    'Content-Type': 'application\u002Fjson',\n  };\n\n  try {\n    \u002F\u002F 1. Create HubSpot Contact\n    const contactRes = await axios.post(\n      'https:\u002F\u002Fapi.hubapi.com\u002Fcrm\u002Fv3\u002Fobjects\u002Fcontacts',\n      {\n        properties: {\n          firstname: lead.firstName,\n          lastname: lead.lastName || '',\n          phone: lead.phone,\n          telegram_username: lead.telegramUsername ? `@${lead.telegramUsername}` : 'N\u002FA',\n          hs_lead_status: 'NEW',\n        },\n      },\n      { headers }\n    );\n\n    const contactId = contactRes.data.id;\n\n    \u002F\u002F 2. Create HubSpot Deal with custom priority tags\n    const dealRes = await axios.post(\n      'https:\u002F\u002Fapi.hubapi.com\u002Fcrm\u002Fv3\u002Fobjects\u002Fdeals',\n      {\n        properties: {\n          dealname: `Lead: ${lead.firstName} - ${lead.answers.projectType.toUpperCase()}`,\n          dealstage: 'appointmentscheduled',\n          priority: lead.score.priority, \u002F\u002F 'HIGH', 'MEDIUM', or 'LOW'\n          description: `Score: ${lead.score.score}\u002F100. Budget: ${lead.answers.budget}. Timeline: ${lead.answers.timeline}`,\n        },\n      },\n      { headers }\n    );\n\n    const dealId = dealRes.data.id;\n\n    \u002F\u002F 3. Associate Deal with Contact\n    await axios.put(\n      `https:\u002F\u002Fapi.hubapi.com\u002Fcrm\u002Fv3\u002Fobjects\u002Fdeals\u002F${dealId}\u002Fassociations\u002Fcontacts\u002F${contactId}\u002Fdeal_to_contact`,\n      {},\n      { headers }\n    );\n\n    return { contactId, dealId };\n  } catch (error: any) {\n    console.error('[CRM Sync Error] HubSpot registration failed:', error.response?.data || error.message);\n    throw new Error('CRM Sync Failed');\n  }\n}\n",[42,10319,10320,10334,10338,10347,10358,10369,10380,10391,10401,10412,10416,10420,10442,10458,10469,10484,10495,10500,10504,10510,10515,10533,10540,10544,10549,10554,10565,10570,10597,10607,10612,10616,10621,10626,10630,10642,10646,10651,10668,10675,10679,10683,10724,10734,10742,10793,10797,10801,10805,10809,10813,10825,10829,10834,10845,10864,10869,10873,10877,10881,10888,10904,10923,10938,10942],{"__ignoreMap":40},[81,10321,10322,10324,10327,10329,10332],{"class":83,"line":84},[81,10323,88],{"class":87},[81,10325,10326],{"class":91}," axios ",[81,10328,95],{"class":87},[81,10330,10331],{"class":98}," 'axios'",[81,10333,102],{"class":91},[81,10335,10336],{"class":83,"line":105},[81,10337,109],{"emptyLinePlaceholder":108},[81,10339,10340,10342,10345],{"class":83,"line":112},[81,10341,115],{"class":87},[81,10343,10344],{"class":118}," LeadPayload",[81,10346,122],{"class":91},[81,10348,10349,10352,10354,10356],{"class":83,"line":125},[81,10350,10351],{"class":128},"  firstName",[81,10353,132],{"class":87},[81,10355,149],{"class":135},[81,10357,102],{"class":91},[81,10359,10360,10363,10365,10367],{"class":83,"line":141},[81,10361,10362],{"class":128},"  lastName",[81,10364,160],{"class":87},[81,10366,149],{"class":135},[81,10368,102],{"class":91},[81,10370,10371,10374,10376,10378],{"class":83,"line":154},[81,10372,10373],{"class":128},"  phone",[81,10375,132],{"class":87},[81,10377,149],{"class":135},[81,10379,102],{"class":91},[81,10381,10382,10385,10387,10389],{"class":83,"line":167},[81,10383,10384],{"class":128},"  telegramUsername",[81,10386,160],{"class":87},[81,10388,149],{"class":135},[81,10390,102],{"class":91},[81,10392,10393,10395,10397,10399],{"class":83,"line":179},[81,10394,9941],{"class":128},[81,10396,132],{"class":87},[81,10398,9934],{"class":118},[81,10400,102],{"class":91},[81,10402,10403,10406,10408,10410],{"class":83,"line":191},[81,10404,10405],{"class":128},"  answers",[81,10407,132],{"class":87},[81,10409,9872],{"class":118},[81,10411,102],{"class":91},[81,10413,10414],{"class":83,"line":204},[81,10415,207],{"class":91},[81,10417,10418],{"class":83,"line":210},[81,10419,109],{"emptyLinePlaceholder":108},[81,10421,10422,10424,10426,10428,10431,10433,10436,10438,10440],{"class":83,"line":215},[81,10423,262],{"class":87},[81,10425,4169],{"class":87},[81,10427,265],{"class":87},[81,10429,10430],{"class":118}," createHubSpotLead",[81,10432,271],{"class":91},[81,10434,10435],{"class":128},"lead",[81,10437,132],{"class":87},[81,10439,10344],{"class":118},[81,10441,5223],{"class":91},[81,10443,10444,10446,10449,10451,10453,10456],{"class":83,"line":225},[81,10445,303],{"class":87},[81,10447,10448],{"class":135}," token",[81,10450,309],{"class":87},[81,10452,4987],{"class":91},[81,10454,10455],{"class":135},"HUBSPOT_ACCESS_TOKEN",[81,10457,102],{"class":91},[81,10459,10460,10462,10465,10467],{"class":83,"line":237},[81,10461,303],{"class":87},[81,10463,10464],{"class":135}," headers",[81,10466,309],{"class":87},[81,10468,122],{"class":91},[81,10470,10471,10474,10477,10480,10482],{"class":83,"line":249},[81,10472,10473],{"class":91},"    Authorization: ",[81,10475,10476],{"class":98},"`Bearer ${",[81,10478,10479],{"class":91},"token",[81,10481,512],{"class":98},[81,10483,2612],{"class":91},[81,10485,10486,10489,10491,10493],{"class":83,"line":254},[81,10487,10488],{"class":98},"    'Content-Type'",[81,10490,2606],{"class":91},[81,10492,5040],{"class":98},[81,10494,2612],{"class":91},[81,10496,10497],{"class":83,"line":259},[81,10498,10499],{"class":91},"  };\n",[81,10501,10502],{"class":83,"line":300},[81,10503,109],{"emptyLinePlaceholder":108},[81,10505,10506,10508],{"class":83,"line":321},[81,10507,747],{"class":87},[81,10509,122],{"class":91},[81,10511,10512],{"class":83,"line":345},[81,10513,10514],{"class":395},"    \u002F\u002F 1. Create HubSpot Contact\n",[81,10516,10517,10519,10522,10524,10526,10529,10531],{"class":83,"line":351},[81,10518,755],{"class":87},[81,10520,10521],{"class":135}," contactRes",[81,10523,309],{"class":87},[81,10525,4210],{"class":87},[81,10527,10528],{"class":91}," axios.",[81,10530,5016],{"class":118},[81,10532,5019],{"class":91},[81,10534,10535,10538],{"class":83,"line":366},[81,10536,10537],{"class":98},"      'https:\u002F\u002Fapi.hubapi.com\u002Fcrm\u002Fv3\u002Fobjects\u002Fcontacts'",[81,10539,2612],{"class":91},[81,10541,10542],{"class":83,"line":381},[81,10543,4239],{"class":91},[81,10545,10546],{"class":83,"line":387},[81,10547,10548],{"class":91},"        properties: {\n",[81,10550,10551],{"class":83,"line":392},[81,10552,10553],{"class":91},"          firstname: lead.firstName,\n",[81,10555,10556,10559,10561,10563],{"class":83,"line":399},[81,10557,10558],{"class":91},"          lastname: lead.lastName ",[81,10560,8437],{"class":87},[81,10562,8113],{"class":98},[81,10564,2612],{"class":91},[81,10566,10567],{"class":83,"line":452},[81,10568,10569],{"class":91},"          phone: lead.phone,\n",[81,10571,10572,10575,10577,10580,10582,10584,10587,10589,10592,10595],{"class":83,"line":457},[81,10573,10574],{"class":91},"          telegram_username: lead.telegramUsername ",[81,10576,791],{"class":87},[81,10578,10579],{"class":98}," `@${",[81,10581,10435],{"class":91},[81,10583,62],{"class":98},[81,10585,10586],{"class":91},"telegramUsername",[81,10588,512],{"class":98},[81,10590,10591],{"class":87}," :",[81,10593,10594],{"class":98}," 'N\u002FA'",[81,10596,2612],{"class":91},[81,10598,10599,10602,10605],{"class":83,"line":463},[81,10600,10601],{"class":91},"          hs_lead_status: ",[81,10603,10604],{"class":98},"'NEW'",[81,10606,2612],{"class":91},[81,10608,10609],{"class":83,"line":476},[81,10610,10611],{"class":91},"        },\n",[81,10613,10614],{"class":83,"line":518},[81,10615,3232],{"class":91},[81,10617,10618],{"class":83,"line":538},[81,10619,10620],{"class":91},"      { headers }\n",[81,10622,10623],{"class":83,"line":543},[81,10624,10625],{"class":91},"    );\n",[81,10627,10628],{"class":83,"line":549},[81,10629,109],{"emptyLinePlaceholder":108},[81,10631,10632,10634,10637,10639],{"class":83,"line":555},[81,10633,755],{"class":87},[81,10635,10636],{"class":135}," contactId",[81,10638,309],{"class":87},[81,10640,10641],{"class":91}," contactRes.data.id;\n",[81,10643,10644],{"class":83,"line":568},[81,10645,109],{"emptyLinePlaceholder":108},[81,10647,10648],{"class":83,"line":588},[81,10649,10650],{"class":395},"    \u002F\u002F 2. Create HubSpot Deal with custom priority tags\n",[81,10652,10653,10655,10658,10660,10662,10664,10666],{"class":83,"line":599},[81,10654,755],{"class":87},[81,10656,10657],{"class":135}," dealRes",[81,10659,309],{"class":87},[81,10661,4210],{"class":87},[81,10663,10528],{"class":91},[81,10665,5016],{"class":118},[81,10667,5019],{"class":91},[81,10669,10670,10673],{"class":83,"line":609},[81,10671,10672],{"class":98},"      'https:\u002F\u002Fapi.hubapi.com\u002Fcrm\u002Fv3\u002Fobjects\u002Fdeals'",[81,10674,2612],{"class":91},[81,10676,10677],{"class":83,"line":614},[81,10678,4239],{"class":91},[81,10680,10681],{"class":83,"line":620},[81,10682,10548],{"class":91},[81,10684,10685,10688,10691,10693,10695,10698,10701,10703,10705,10707,10709,10712,10714,10717,10720,10722],{"class":83,"line":632},[81,10686,10687],{"class":91},"          dealname: ",[81,10689,10690],{"class":98},"`Lead: ${",[81,10692,10435],{"class":91},[81,10694,62],{"class":98},[81,10696,10697],{"class":91},"firstName",[81,10699,10700],{"class":98},"} - ${",[81,10702,10435],{"class":91},[81,10704,62],{"class":98},[81,10706,9992],{"class":91},[81,10708,62],{"class":98},[81,10710,10711],{"class":91},"projectType",[81,10713,62],{"class":98},[81,10715,10716],{"class":118},"toUpperCase",[81,10718,10719],{"class":98},"()",[81,10721,512],{"class":98},[81,10723,2612],{"class":91},[81,10725,10726,10729,10732],{"class":83,"line":646},[81,10727,10728],{"class":91},"          dealstage: ",[81,10730,10731],{"class":98},"'appointmentscheduled'",[81,10733,2612],{"class":91},[81,10735,10736,10739],{"class":83,"line":656},[81,10737,10738],{"class":91},"          priority: lead.score.priority, ",[81,10740,10741],{"class":395},"\u002F\u002F 'HIGH', 'MEDIUM', or 'LOW'\n",[81,10743,10744,10747,10750,10752,10754,10757,10759,10761,10764,10766,10768,10770,10772,10775,10778,10780,10782,10784,10786,10789,10791],{"class":83,"line":670},[81,10745,10746],{"class":91},"          description: ",[81,10748,10749],{"class":98},"`Score: ${",[81,10751,10435],{"class":91},[81,10753,62],{"class":98},[81,10755,10756],{"class":91},"score",[81,10758,62],{"class":98},[81,10760,10756],{"class":91},[81,10762,10763],{"class":98},"}\u002F100. Budget: ${",[81,10765,10435],{"class":91},[81,10767,62],{"class":98},[81,10769,9992],{"class":91},[81,10771,62],{"class":98},[81,10773,10774],{"class":91},"budget",[81,10776,10777],{"class":98},"}. Timeline: ${",[81,10779,10435],{"class":91},[81,10781,62],{"class":98},[81,10783,9992],{"class":91},[81,10785,62],{"class":98},[81,10787,10788],{"class":91},"timeline",[81,10790,512],{"class":98},[81,10792,2612],{"class":91},[81,10794,10795],{"class":83,"line":675},[81,10796,10611],{"class":91},[81,10798,10799],{"class":83,"line":681},[81,10800,3232],{"class":91},[81,10802,10803],{"class":83,"line":700},[81,10804,10620],{"class":91},[81,10806,10807],{"class":83,"line":705},[81,10808,10625],{"class":91},[81,10810,10811],{"class":83,"line":717},[81,10812,109],{"emptyLinePlaceholder":108},[81,10814,10815,10817,10820,10822],{"class":83,"line":728},[81,10816,755],{"class":87},[81,10818,10819],{"class":135}," dealId",[81,10821,309],{"class":87},[81,10823,10824],{"class":91}," dealRes.data.id;\n",[81,10826,10827],{"class":83,"line":733},[81,10828,109],{"emptyLinePlaceholder":108},[81,10830,10831],{"class":83,"line":738},[81,10832,10833],{"class":395},"    \u002F\u002F 3. Associate Deal with Contact\n",[81,10835,10836,10838,10840,10843],{"class":83,"line":744},[81,10837,6604],{"class":87},[81,10839,10528],{"class":91},[81,10841,10842],{"class":118},"put",[81,10844,5019],{"class":91},[81,10846,10847,10850,10853,10856,10859,10862],{"class":83,"line":752},[81,10848,10849],{"class":98},"      `https:\u002F\u002Fapi.hubapi.com\u002Fcrm\u002Fv3\u002Fobjects\u002Fdeals\u002F${",[81,10851,10852],{"class":91},"dealId",[81,10854,10855],{"class":98},"}\u002Fassociations\u002Fcontacts\u002F${",[81,10857,10858],{"class":91},"contactId",[81,10860,10861],{"class":98},"}\u002Fdeal_to_contact`",[81,10863,2612],{"class":91},[81,10865,10866],{"class":83,"line":774},[81,10867,10868],{"class":91},"      {},\n",[81,10870,10871],{"class":83,"line":812},[81,10872,10620],{"class":91},[81,10874,10875],{"class":83,"line":825},[81,10876,10625],{"class":91},[81,10878,10879],{"class":83,"line":837},[81,10880,109],{"emptyLinePlaceholder":108},[81,10882,10883,10885],{"class":83,"line":848},[81,10884,369],{"class":87},[81,10886,10887],{"class":91}," { contactId, dealId };\n",[81,10889,10890,10892,10894,10896,10898,10900,10902],{"class":83,"line":853},[81,10891,828],{"class":91},[81,10893,831],{"class":87},[81,10895,357],{"class":91},[81,10897,5231],{"class":128},[81,10899,132],{"class":87},[81,10901,1046],{"class":135},[81,10903,5223],{"class":91},[81,10905,10906,10908,10910,10912,10915,10918,10920],{"class":83,"line":5594},[81,10907,8847],{"class":91},[81,10909,5231],{"class":118},[81,10911,271],{"class":91},[81,10913,10914],{"class":98},"'[CRM Sync Error] HubSpot registration failed:'",[81,10916,10917],{"class":91},", error.response?.data ",[81,10919,8437],{"class":87},[81,10921,10922],{"class":91}," error.message);\n",[81,10924,10925,10927,10929,10931,10933,10936],{"class":83,"line":5599},[81,10926,4363],{"class":87},[81,10928,312],{"class":87},[81,10930,4368],{"class":118},[81,10932,271],{"class":91},[81,10934,10935],{"class":98},"'CRM Sync Failed'",[81,10937,342],{"class":91},[81,10939,10940],{"class":83,"line":5624},[81,10941,384],{"class":91},[81,10943,10944],{"class":83,"line":5637},[81,10945,207],{"class":91},[26,10947,10949],{"id":10948},"_3-designing-interactive-telegram-inline-controls","3. Designing Interactive Telegram Inline Controls",[11,10951,10952,10953,10956],{},"To ensure high conversions, avoid text inputs where possible. Rely on ",[15,10954,10955],{},"Inline Keyboards"," for selecting options. Here is a sample helper for sending qualification questions:",[34,10958,10960],{"className":75,"code":10959,"language":77,"meta":40,"style":40},"import TelegramBot from 'node-telegram-bot-api';\n\nexport function sendBudgetQuestion(bot: TelegramBot, chatId: number) {\n  bot.sendMessage(chatId, \"What is your approximate budget for the project?\", {\n    reply_markup: {\n      inline_keyboard: [\n        [\n          { text: \"Under $2k\", callback_data: \"budget_under_2k\" },\n          { text: \"$2k - $10k\", callback_data: \"budget_2k_10k\" }\n        ],\n        [\n          { text: \"Over $10k\", callback_data: \"budget_over_10k\" }\n        ]\n      ]\n    }\n  });\n}\n",[42,10961,10962,10974,10978,11007,11021,11026,11031,11036,11052,11066,11071,11075,11089,11094,11098,11102,11106],{"__ignoreMap":40},[81,10963,10964,10966,10968,10970,10972],{"class":83,"line":84},[81,10965,88],{"class":87},[81,10967,8468],{"class":91},[81,10969,95],{"class":87},[81,10971,8473],{"class":98},[81,10973,102],{"class":91},[81,10975,10976],{"class":83,"line":105},[81,10977,109],{"emptyLinePlaceholder":108},[81,10979,10980,10982,10984,10987,10989,10992,10994,10996,10998,11001,11003,11005],{"class":83,"line":112},[81,10981,262],{"class":87},[81,10983,265],{"class":87},[81,10985,10986],{"class":118}," sendBudgetQuestion",[81,10988,271],{"class":91},[81,10990,10991],{"class":128},"bot",[81,10993,132],{"class":87},[81,10995,8493],{"class":118},[81,10997,281],{"class":91},[81,10999,11000],{"class":128},"chatId",[81,11002,132],{"class":87},[81,11004,136],{"class":135},[81,11006,5223],{"class":91},[81,11008,11009,11012,11014,11016,11019],{"class":83,"line":125},[81,11010,11011],{"class":91},"  bot.",[81,11013,8783],{"class":118},[81,11015,8868],{"class":91},[81,11017,11018],{"class":98},"\"What is your approximate budget for the project?\"",[81,11020,4091],{"class":91},[81,11022,11023],{"class":83,"line":141},[81,11024,11025],{"class":91},"    reply_markup: {\n",[81,11027,11028],{"class":83,"line":154},[81,11029,11030],{"class":91},"      inline_keyboard: [\n",[81,11032,11033],{"class":83,"line":167},[81,11034,11035],{"class":91},"        [\n",[81,11037,11038,11041,11044,11047,11050],{"class":83,"line":179},[81,11039,11040],{"class":91},"          { text: ",[81,11042,11043],{"class":98},"\"Under $2k\"",[81,11045,11046],{"class":91},", callback_data: ",[81,11048,11049],{"class":98},"\"budget_under_2k\"",[81,11051,3442],{"class":91},[81,11053,11054,11056,11059,11061,11064],{"class":83,"line":191},[81,11055,11040],{"class":91},[81,11057,11058],{"class":98},"\"$2k - $10k\"",[81,11060,11046],{"class":91},[81,11062,11063],{"class":98},"\"budget_2k_10k\"",[81,11065,912],{"class":91},[81,11067,11068],{"class":83,"line":204},[81,11069,11070],{"class":91},"        ],\n",[81,11072,11073],{"class":83,"line":210},[81,11074,11035],{"class":91},[81,11076,11077,11079,11082,11084,11087],{"class":83,"line":215},[81,11078,11040],{"class":91},[81,11080,11081],{"class":98},"\"Over $10k\"",[81,11083,11046],{"class":91},[81,11085,11086],{"class":98},"\"budget_over_10k\"",[81,11088,912],{"class":91},[81,11090,11091],{"class":83,"line":225},[81,11092,11093],{"class":91},"        ]\n",[81,11095,11096],{"class":83,"line":237},[81,11097,3156],{"class":91},[81,11099,11100],{"class":83,"line":249},[81,11101,1193],{"class":91},[81,11103,11104],{"class":83,"line":254},[81,11105,1198],{"class":91},[81,11107,11108],{"class":83,"line":259},[81,11109,207],{"class":91},[26,11111,1294],{"id":1293},[1296,11113,11114,11120,11128],{},[1236,11115,11116,11119],{},[1274,11117,8938],{"href":8936,"rel":11118},[1304]," — inline keyboards, callback queries, and contact sharing",[1236,11121,11122,11127],{},[1274,11123,11126],{"href":11124,"rel":11125},"https:\u002F\u002Fdevelopers.hubspot.com\u002Fdocs\u002Fapi\u002Foverview",[1304],"HubSpot API documentation"," — contact and deal endpoints",[1236,11129,11130,11135],{},[1274,11131,11134],{"href":11132,"rel":11133},"https:\u002F\u002Fwww.amocrm.com\u002Fdevelopers\u002F",[1304],"amoCRM developer portal"," — the API used in the example above",[11,11137,11138],{},"By verifying user data, implementing scoring, and establishing direct CRM pipelines, businesses can reduce lead response times to under 5 seconds while ensuring that managers spend time talking only to qualified clients.",[11,11140,11141,11142,11144,11145,11148],{},"In my custom bot projects like ",[1274,11143,1277],{"href":1276},", we leverage these webhook workflows to sync user activities directly into sales tools. For an even more advanced system, you can connect your CRM database with a ",[1274,11146,11147],{"href":1281},"RAG AI Customer Support Bot"," to resolve user issues before a manager is paged.",[11,11150,11151,11152,2919,11154,11156],{},"If you are looking to build a custom Telegram bot with advanced CRM integrations (amoCRM, Bitrix24, HubSpot) tailored for your sales processes, explore my ",[1274,11153,1324],{"href":1323},[1274,11155,2922],{"href":1328}," to plan your funnel.",[1332,11158,11159],{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}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 pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}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":11161},[11162,11163,11164,11165,11166],{"id":9844,"depth":105,"text":9845},{"id":9854,"depth":105,"text":9855},{"id":10307,"depth":105,"text":10308},{"id":10948,"depth":105,"text":10949},{"id":1293,"depth":105,"text":1294},"2026-05-21","A complete developer's guide on constructing an interactive qualification Telegram bot that processes lead inputs, runs scoring algorithms, and syncs pipelines with CRM APIs.",[11170,11173,11176],{"q":11171,"a":11172},"Why does a bot need lead scoring if the CRM already collects leads?","Scoring sorts leads before a manager sees them: a lead with a $10,000+ budget and an urgent timeline lands in the CRM with high priority and an instant notification, while cold leads don't distract the sales team. The most profitable clients get answered first.",{"q":11174,"a":11175},"Which CRMs can a Telegram bot integrate with?","Any system with an API: HubSpot, amoCRM, Bitrix24, as well as in-house CRMs. The bot creates a contact, opens a deal at the right pipeline stage, and maps survey answers to custom fields.",{"q":11177,"a":11178},"Why are buttons better than free-text input in a bot?","Inline buttons raise survey completion rates: the user doesn't have to type, and the bot receives predictable data without parsing free text. Manual input stays only where unavoidable — phone number or email.","\u002Fimages\u002Fblog\u002Fblog_crm_integration.jpg",{},"\u002Fblog\u002Fen\u002Ftelegram-bot-crm-integration",{"title":9827,"description":11168},"blog\u002Fen\u002Ftelegram-bot-crm-integration",[1366,11185,6157,1369,11186],"CRM","Automation","-PUJZTbwdtFTzBD2kuyySpWErxpjHgzP_BsB63AwgxE",{"id":11189,"title":11190,"body":11191,"date":11448,"description":11449,"extension":1348,"faq":11450,"image":11236,"lang":1360,"meta":11451,"navigation":108,"path":11452,"published":108,"readTime":141,"seo":11453,"stem":11454,"tags":11455,"updated":1346,"__hash__":11463},"blog\u002Fblog\u002Fen\u002Fezswitch-macos-switcher.md","EzSwitch — Quick Keyboard Layout Switcher for macOS",{"type":8,"value":11192,"toc":11436},[11193,11204,11217,11220,11224,11227,11231,11238,11248,11263,11267,11278,11286,11290,11293,11297,11303,11349,11356,11360,11367,11373,11377,11384,11411,11415,11423,11433],[11,11194,11195,11196,11199,11200,11203],{},"For developers and writers who constantly switch between multiple languages (e.g., writing code in English and comments or docs in Russian), physical keyboard layout switching is a constant friction point. Accidentally typing in the wrong layout (",[42,11197,11198],{},"ghbdtn"," instead of ",[42,11201,11202],{},"привет",") breaks the working flow.",[11,11205,11206,11207,11212,11213,11216],{},"To solve this once and for all, I developed ",[1274,11208,11211],{"href":11209,"rel":11210},"https:\u002F\u002Fgithub.com\u002Falexrel\u002FEzSwitch",[1304],"EzSwitch"," — a lightweight macOS menu bar utility written in ",[15,11214,11215],{},"Swift and SwiftUI"," that automates keyboard layout changes and text recovery.",[11,11218,11219],{},"In this article, I will walk through the features of EzSwitch and explain how the key features operate under the hood using macOS system APIs.",[26,11221,11223],{"id":11222},"core-features-of-ezswitch","Core Features of EzSwitch",[11,11225,11226],{},"The utility solves two major problems: fast layout switching using physical modifier keys, and instant correction of already typed text.",[864,11228,11230],{"id":11229},"_1-command-key-layout-switching","1. Command-Key Layout Switching",[11232,11233],"blog-image",{"alt":11234,"caption":11234,"layout":11235,"src":11236,"size":11237},"EzSwitch interface in the menu bar","right","\u002Fimages\u002Fezswitch\u002FezSwitch-app.png","sm",[11,11239,11240,11241,6065,11244,11247],{},"Instead of utilizing standard macOS cycling combinations like ",[42,11242,11243],{},"Cmd+Space",[42,11245,11246],{},"Ctrl+Space",", EzSwitch lets you bind layouts to individual taps of modifier keys:",[1296,11249,11250,11257],{},[1236,11251,11252,11253,11256],{},"Tapping the ",[15,11254,11255],{},"left Command"," key ➔ forces the active layout to your left language (e.g., English).",[1236,11258,11252,11259,11262],{},[15,11260,11261],{},"right Command"," key ➔ forces the active layout to your right language (e.g., Russian).\nThis is fast and error-free because you do not need to check which layout is currently active. You simply press the side corresponding to your target language.",[864,11264,11266],{"id":11265},"_2-double-shift-text-transformation","2. Double-Shift Text Transformation",[11,11268,11269,11270,11273,11274,11277],{},"If you forget to switch layouts and type a word in the wrong mode, simply highlight the text (or place your cursor after the word) and double-tap ",[15,11271,11272],{},"Shift"," (or ",[15,11275,11276],{},"Option","). The text instantly transforms into the correct layout.",[1296,11279,11280,11283],{},[1236,11281,11282],{},"Supports converting both highlighted text selections or the last typed word before the cursor.",[1236,11284,11285],{},"Automatically switches the active keyboard layout to follow the transformed text.",[26,11287,11289],{"id":11288},"how-it-works-under-the-hood","How it Works Under the Hood",[11,11291,11292],{},"Building low-level system software for macOS requires deep interaction with Core Graphics and Carbon APIs.",[864,11294,11296],{"id":11295},"intercepting-keystrokes-event-taps","Intercepting Keystrokes (Event Taps)",[11,11298,11299,11300,132],{},"To detect modifier double-taps and single Command key taps, the application sets up a low-level event listener in Swift using ",[15,11301,11302],{},"CGEventTap",[34,11304,11308],{"className":11305,"code":11306,"language":11307,"meta":40,"style":40},"language-swift shiki shiki-themes github-dark","let eventTap = CGEvent.tapCreate(\n    tap: .cgSessionEventTap,\n    place: .headInsertEventTap,\n    options: .defaultTap,\n    eventsOfInterest: [.keyDown, .keyUp, .flagsChanged],\n    callback: myEventTapCallback,\n    userInfo: nil\n)\n","swift",[42,11309,11310,11315,11320,11325,11330,11335,11340,11345],{"__ignoreMap":40},[81,11311,11312],{"class":83,"line":84},[81,11313,11314],{},"let eventTap = CGEvent.tapCreate(\n",[81,11316,11317],{"class":83,"line":105},[81,11318,11319],{},"    tap: .cgSessionEventTap,\n",[81,11321,11322],{"class":83,"line":112},[81,11323,11324],{},"    place: .headInsertEventTap,\n",[81,11326,11327],{"class":83,"line":125},[81,11328,11329],{},"    options: .defaultTap,\n",[81,11331,11332],{"class":83,"line":141},[81,11333,11334],{},"    eventsOfInterest: [.keyDown, .keyUp, .flagsChanged],\n",[81,11336,11337],{"class":83,"line":154},[81,11338,11339],{},"    callback: myEventTapCallback,\n",[81,11341,11342],{"class":83,"line":167},[81,11343,11344],{},"    userInfo: nil\n",[81,11346,11347],{"class":83,"line":179},[81,11348,515],{},[11,11350,11351,11352,11355],{},"This requires registering the application for the ",[15,11353,11354],{},"Input Monitoring"," system permission in macOS Settings.",[864,11357,11359],{"id":11358},"auto-generated-key-mappings-via-uckeytranslate","Auto-Generated Key Mappings via UCKeyTranslate",[11,11361,11362,11363,11366],{},"To translate characters cleanly between any pair of languages, the converter needs a key translation map. Instead of hardcoding character tables, EzSwitch queries the Carbon ",[15,11364,11365],{},"UCKeyTranslate"," API on launch.\nIt reads the user's active keyboard layouts, simulates virtual keypresses for all 48 physical key codes under various modifier states (base, Shift, Option, Shift+Option), and compiles a character map on the fly. This ensures the text converter works out of the box with any layout pair installed on the system.",[11232,11368],{"alt":11369,"caption":11370,"layout":11371,"src":11372},"Generated keyboard layout character map","Keyboard layout character map generated at runtime via Carbon UCKeyTranslate API","center","\u002Fimages\u002Fezswitch\u002FezSwitch-mapper.png",[864,11374,11376],{"id":11375},"text-replacement-mechanism","Text Replacement Mechanism",[11,11378,11379,11380,11383],{},"To transform typed text, the app leverages macOS ",[15,11381,11382],{},"Accessibility APIs"," to simulate user keyboard actions:",[1233,11385,11386,11396,11402,11405],{},[1236,11387,11388,11389,11273,11392,11395],{},"Simulates ",[42,11390,11391],{},"Shift + Option + Left",[42,11393,11394],{},"Ctrl + Shift + Left"," in IDEs) to select the last word.",[1236,11397,11398,11399,443],{},"Copies the highlighted text to the clipboard (",[42,11400,11401],{},"Cmd+C",[1236,11403,11404],{},"Translates the string character-by-character using the active mapping.",[1236,11406,11407,11408,443],{},"Pastes the corrected text back (",[42,11409,11410],{},"Cmd+V",[26,11412,11414],{"id":11413},"conclusion","Conclusion",[11,11416,11417,11418,11422],{},"EzSwitch is a fast, highly optimized open-source utility that integrates cleanly into macOS, saving time during daily multi-lingual workflows. The project is fully open source — check out the ",[1274,11419,11421],{"href":11209,"rel":11420},[1304],"EzSwitch GitHub repository"," to browse the code, open it in Xcode, or build it yourself.",[11,11424,11425,11426,11428,11429,62],{},"Working on a macOS utility or a tool with complex system integrations? I'm happy to discuss architecture or tricky API challenges — book a ",[1274,11427,3807],{"href":1328}," or see ",[1274,11430,11432],{"href":11431},"\u002Fabout","what I build and work on",[1332,11434,11435],{},"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":11437},[11438,11442,11447],{"id":11222,"depth":105,"text":11223,"children":11439},[11440,11441],{"id":11229,"depth":112,"text":11230},{"id":11265,"depth":112,"text":11266},{"id":11288,"depth":105,"text":11289,"children":11443},[11444,11445,11446],{"id":11295,"depth":112,"text":11296},{"id":11358,"depth":112,"text":11359},{"id":11375,"depth":112,"text":11376},{"id":11413,"depth":105,"text":11414},"2026-05-17","How I built a lightweight Swift\u002FSwiftUI menu bar utility for smart layout switching and double-tap text transformation on macOS.",null,{},"\u002Fblog\u002Fen\u002Fezswitch-macos-switcher",{"title":11190,"description":11449},"blog\u002Fen\u002Fezswitch-macos-switcher",[11456,11457,11458,11459,11460,11461,11462],"Mac","Tool","SwiftUI","CLI","Open Source","macOS","Productivity","Y8IDRlgWtoOKp_6vdei2b3ZSLnnJD_DkOGvbKxXSGQ4",1784561414095]