<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Aravind Govindhasamy</title>
    <description>The latest articles on DEV Community by Aravind Govindhasamy (@aravind-govindhasamy).</description>
    <link>https://dev.to/aravind-govindhasamy</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4200811%2Fe645e166-74a2-46f0-8d3d-22a6f604c53a.jpg</url>
      <title>DEV Community: Aravind Govindhasamy</title>
      <link>https://dev.to/aravind-govindhasamy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aravind-govindhasamy"/>
    <language>en</language>
    <item>
      <title>SMS and WhatsApp sign-in codes for Supabase with MSG91</title>
      <dc:creator>Aravind Govindhasamy</dc:creator>
      <pubDate>Sun, 01 Nov 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/aravind-govindhasamy/sms-and-whatsapp-sign-in-codes-for-supabase-with-msg91-1kop</link>
      <guid>https://dev.to/aravind-govindhasamy/sms-and-whatsapp-sign-in-codes-for-supabase-with-msg91-1kop</guid>
      <description>&lt;p&gt;Phone sign-in is how many users in India expect to log in. Supabase Auth generates and checks the codes; you just need to deliver them. Supabase's built-in SMS providers don't cover every country's rules well, and none send WhatsApp, so Supabase lets you take over delivery with a &lt;strong&gt;Send SMS hook&lt;/strong&gt;. This guide wires that hook to &lt;a href="https://msg91.com" rel="noopener noreferrer"&gt;MSG91&lt;/a&gt;, which handles Indian SMS regulations and WhatsApp.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;A user enters their phone number; your frontend calls &lt;code&gt;supabase.auth.signInWithOtp({ phone })&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Supabase generates a six-digit code and calls &lt;strong&gt;your hook&lt;/strong&gt; over HTTPS with the phone number and code.&lt;/li&gt;
&lt;li&gt;Your FastAPI endpoint verifies the request came from Supabase and sends the code through MSG91.&lt;/li&gt;
&lt;li&gt;The user types the code; &lt;code&gt;supabase.auth.verifyOtp({ phone, token, type: "sms" })&lt;/code&gt; signs them in.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  1. Get your MSG91 auth key
&lt;/h2&gt;

&lt;p&gt;Sign up at MSG91 and copy your &lt;strong&gt;authkey&lt;/strong&gt; from the dashboard. Every API call sends it in an &lt;code&gt;authkey&lt;/code&gt; header.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MSG91_AUTH_KEY=...

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. For SMS in India: register on DLT
&lt;/h2&gt;

&lt;p&gt;Indian telecom rules (TRAI) require every business SMS to be registered on a &lt;strong&gt;DLT&lt;/strong&gt; platform run by the operators:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Register your business as a &lt;strong&gt;Principal Entity&lt;/strong&gt; and get an Entity ID.&lt;/li&gt;
&lt;li&gt;Register a &lt;strong&gt;header&lt;/strong&gt; (the sender name users see, usually 6 letters).&lt;/li&gt;
&lt;li&gt;Register a &lt;strong&gt;content template&lt;/strong&gt; for your code, for example: &lt;code&gt;{#var#} is your Example sign-in code. It expires in 10 minutes.&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Add the approved template in MSG91 and note its &lt;strong&gt;template id&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Messages that don't match an approved template are dropped by the operators. Approval takes from a few hours to a few days, so start early.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. For WhatsApp: an authentication template
&lt;/h2&gt;

&lt;p&gt;Connect a WhatsApp Business number in MSG91 and create an &lt;strong&gt;Authentication&lt;/strong&gt; template. Meta approves it, usually quickly. Authentication templates put the code in the message body and a "copy code" button.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;MSG91_WHATSAPP_NUMBER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;919876543210 &lt;span class="c"&gt;# your integrated number, country code first&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Write the hook endpoint
&lt;/h2&gt;

&lt;p&gt;Supabase signs hook requests with the &lt;a href="https://www.standardwebhooks.com" rel="noopener noreferrer"&gt;Standard Webhooks&lt;/a&gt; scheme. The secret Supabase gives you looks like &lt;code&gt;v1,whsec_&amp;lt;base64&amp;gt;&lt;/code&gt;; strip the prefix and verify the &lt;code&gt;webhook-id&lt;/code&gt;, &lt;code&gt;webhook-timestamp&lt;/code&gt; and &lt;code&gt;webhook-signature&lt;/code&gt; headers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi.responses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;JSONResponse&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeprefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1,whsec_&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;msg_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;webhook-id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;webhook-timestamp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stale&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;signed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;msg_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;
    &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compare_digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
               &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;webhook-signature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;()):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bad signature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/hooks/send-sms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send_sms_hook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SEND_SMS_HOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;KeyError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JSONResponse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http_code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invalid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;otp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;phone&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;lstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;otp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://control.msg91.com/api/v5/otp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mobile&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;template_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;MSG91_OTP_TEMPLATE_ID&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OTP&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;otp&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;authkey&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;MSG91_AUTH_KEY&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;200&lt;/code&gt; with an empty body tells Supabase the code was sent. For WhatsApp, send your approved template through MSG91's WhatsApp API instead, with the code as the body variable and the button value.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Turn it on in Supabase
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Authentication → Providers → Phone:&lt;/strong&gt; enable phone sign-in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication → Hooks:&lt;/strong&gt; add a &lt;strong&gt;Send SMS&lt;/strong&gt; hook of type &lt;strong&gt;HTTPS&lt;/strong&gt; , URL &lt;code&gt;https://api.example.com/hooks/send-sms&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Generate the hook secret there and put it in your API's environment as &lt;code&gt;SEND_SMS_HOOK_SECRET&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  6. Test it
&lt;/h2&gt;

&lt;p&gt;Sign in with your own number. If no message arrives, check, in order: your API's logs (did the hook arrive and verify?), MSG91's delivery reports (did it send?), and the DLT template (does the text match exactly?).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost note:&lt;/strong&gt; every code is a paid message, so keep Supabase's rate limits on and consider a CAPTCHA on the form (&lt;a href="https://starter.budeglobal.in/blog/cloudflare-turnstile-supabase-auth" rel="noopener noreferrer"&gt;Turnstile guide&lt;/a&gt;) to stop SMS pumping attacks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using it with FastAPI Supabase Starter
&lt;/h2&gt;

&lt;p&gt;The hook endpoint, signature checks, MSG91 client and a delivery log are built in. Set &lt;code&gt;MSG91_AUTH_KEY&lt;/code&gt;, &lt;code&gt;SEND_SMS_HOOK_SECRET&lt;/code&gt; and (for WhatsApp) &lt;code&gt;MSG91_WHATSAPP_NUMBER&lt;/code&gt; with &lt;code&gt;OTP_CHANNEL=whatsapp&lt;/code&gt;, add the hook in Supabase, and map the &lt;code&gt;otp&lt;/code&gt; template to your DLT template id or WhatsApp template name under &lt;strong&gt;Admin › Messaging&lt;/strong&gt;. See &lt;a href="https://starter.budeglobal.in/help/sign-in-methods" rel="noopener noreferrer"&gt;Sign-in methods&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>authentication</category>
      <category>whatsapp</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Razorpay and Stripe in one FastAPI app, without trusting the browser</title>
      <dc:creator>Aravind Govindhasamy</dc:creator>
      <pubDate>Fri, 30 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/aravind-govindhasamy/razorpay-and-stripe-in-one-fastapi-app-without-trusting-the-browser-2216</link>
      <guid>https://dev.to/aravind-govindhasamy/razorpay-and-stripe-in-one-fastapi-app-without-trusting-the-browser-2216</guid>
      <description>&lt;p&gt;If you sell to customers in India, you want UPI, and for that you want Razorpay. If you also sell abroad, Stripe Checkout is the easiest way to take cards. The starter does both from one FastAPI app, with one rule that matters more than either provider: &lt;strong&gt;the browser never decides that a payment succeeded.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Prices live on the server
&lt;/h2&gt;

&lt;p&gt;Products and their prices are a dictionary in &lt;code&gt;backend/app/api/payments.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;PRODUCTS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pro&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Pro upgrade&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;plan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pro&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# profiles.plan after payment
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prices&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stripe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;usd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;razorpay&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;149900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Amounts are in minor units (cents and paise). The client sends a product id, never an amount, so nobody can buy the Pro plan for one rupee by editing a request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pending until a webhook says otherwise
&lt;/h2&gt;

&lt;p&gt;Checkout creates a &lt;code&gt;pending&lt;/code&gt; row in &lt;code&gt;payments&lt;/code&gt; and returns the provider's checkout URL or order. When the customer pays, the provider calls a webhook:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stripe: &lt;code&gt;/payments/webhooks/stripe&lt;/code&gt;, with the &lt;code&gt;checkout.session.*&lt;/code&gt; events&lt;/li&gt;
&lt;li&gt;Razorpay: &lt;code&gt;/payments/webhooks/razorpay&lt;/code&gt;, with &lt;code&gt;order.paid&lt;/code&gt; and &lt;code&gt;payment.failed&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The backend verifies the signature, and only then marks the payment &lt;code&gt;paid&lt;/code&gt; and calls &lt;code&gt;on_paid()&lt;/code&gt;, which upgrades the user's plan. A success page in the browser is just a page; it changes nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exactly once, even when providers retry
&lt;/h2&gt;

&lt;p&gt;Payment providers retry webhooks, sometimes several times. Each event is recorded with its id, and applying it twice is a no-op, so a retried &lt;code&gt;order.paid&lt;/code&gt; can't grant the plan twice or notify the buyer twice. The tests replay webhooks to prove it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Money is never added across currencies
&lt;/h2&gt;

&lt;p&gt;Revenue charts and the admin payments list keep each currency separate. Adding ₹499 to $9 gives a number that means nothing, so the starter never does it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning it on
&lt;/h2&gt;

&lt;p&gt;A provider appears in the UI once its keys are in &lt;code&gt;.env&lt;/code&gt;. Set the webhook URL in the provider's dashboard, pick the events above, and you can take a test payment in a few minutes. The &lt;a href="https://starter.budeglobal.in/help/payments" rel="noopener noreferrer"&gt;payments guide&lt;/a&gt; has the details.&lt;/p&gt;

</description>
      <category>python</category>
      <category>fastapi</category>
      <category>stripe</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Add Google sign-in to Supabase, step by step</title>
      <dc:creator>Aravind Govindhasamy</dc:creator>
      <pubDate>Wed, 28 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/aravind-govindhasamy/add-google-sign-in-to-supabase-step-by-step-1ofb</link>
      <guid>https://dev.to/aravind-govindhasamy/add-google-sign-in-to-supabase-step-by-step-1ofb</guid>
      <description>&lt;p&gt;"Continue with Google" is the sign-in most users reach for. With Supabase Auth it takes about fifteen minutes: a client in Google Cloud, two values pasted into Supabase, and one function call in your frontend.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Google account with access to &lt;a href="https://console.cloud.google.com" rel="noopener noreferrer"&gt;Google Cloud Console&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Your Supabase project&lt;/li&gt;
&lt;li&gt;Your app's URL (for example &lt;code&gt;https://example.com&lt;/code&gt;) and, for development, &lt;code&gt;http://localhost:5173&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Create a Google Cloud project
&lt;/h2&gt;

&lt;p&gt;In Google Cloud Console, pick a project from the selector at the top or create a new one, for example "Example Auth". Keep one project per product, so the consent screen shows the right name.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Set up the consent screen
&lt;/h2&gt;

&lt;p&gt;Open &lt;strong&gt;Google Auth Platform&lt;/strong&gt; (search for "OAuth consent screen" if you can't find it). Fill in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Branding:&lt;/strong&gt; your app name, support email, logo and your homepage, privacy policy and terms links. Users see this when they sign in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audience:&lt;/strong&gt;  &lt;strong&gt;External&lt;/strong&gt; , so any Google account can sign in. While the app is in &lt;strong&gt;Testing&lt;/strong&gt; , only test users you list can sign in; click &lt;strong&gt;Publish app&lt;/strong&gt; when you go live.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data Access (Scopes):&lt;/strong&gt; add &lt;code&gt;openid&lt;/code&gt;. The &lt;code&gt;.../auth/userinfo.email&lt;/code&gt; and &lt;code&gt;.../auth/userinfo.profile&lt;/code&gt; scopes are the defaults. Avoid sensitive scopes: they trigger Google's verification review.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Google may review your branding before your logo shows, which can take a few business days. Sign-in works meanwhile.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Create the OAuth client
&lt;/h2&gt;

&lt;p&gt;Go to &lt;strong&gt;Clients → Create client&lt;/strong&gt; and choose &lt;strong&gt;Web application&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Authorized JavaScript origins:&lt;/strong&gt; your app's origin, with no path: &lt;code&gt;https://example.com&lt;/code&gt;. Add &lt;code&gt;http://localhost:5173&lt;/code&gt; for development, and remove it before launch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authorized redirect URIs:&lt;/strong&gt; Supabase's callback, &lt;strong&gt;not&lt;/strong&gt; your app's URL. Copy it from the Supabase dashboard ( &lt;strong&gt;Authentication → Providers → Google&lt;/strong&gt; , "Callback URL"). It looks like:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://&amp;lt;project-ref&amp;gt;.supabase.co/auth/v1/callback

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Click &lt;strong&gt;Create&lt;/strong&gt; and copy the &lt;strong&gt;Client ID&lt;/strong&gt; and &lt;strong&gt;Client secret&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Enable Google in Supabase
&lt;/h2&gt;

&lt;p&gt;In Supabase, open &lt;strong&gt;Authentication → Providers → Google&lt;/strong&gt; , turn it on, paste the client ID and secret, and save.&lt;/p&gt;

&lt;p&gt;Then open &lt;strong&gt;Authentication → URL Configuration&lt;/strong&gt; :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Site URL:&lt;/strong&gt; &lt;code&gt;https://example.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redirect URLs:&lt;/strong&gt; &lt;code&gt;https://example.com/ **&lt;/code&gt; and &lt;code&gt;http://localhost:5173/**&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Supabase only sends users back to URLs on this list.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Add the button
&lt;/h2&gt;

&lt;p&gt;With supabase-js in a React app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signInWithOAuth&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;google&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;redirectTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/dashboard`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The browser goes to Google, then to Supabase's callback, then back to &lt;code&gt;redirectTo&lt;/code&gt; with a session. Your FastAPI backend receives the same kind of access token as for any other sign-in, so nothing changes there. &lt;a href="https://dev.to/aravind_govindhasamy/supabase-api-keys-explained-publishable-secret-and-jwt-signing-keys-for-fastapi-1320-temp-slug-8568848"&gt;How to verify it&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common errors
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Error&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redirect_uri_mismatch&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The redirect URI in Google must be Supabase's callback URL exactly, including &lt;code&gt;https&lt;/code&gt; and &lt;code&gt;/auth/v1/callback&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Back on the Site URL instead of your page&lt;/td&gt;
&lt;td&gt;Add the &lt;code&gt;redirectTo&lt;/code&gt; URL to Supabase's Redirect URLs list.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Access blocked: app is in testing"&lt;/td&gt;
&lt;td&gt;Add the account as a test user, or publish the app.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Users see &lt;code&gt;&amp;lt;project-ref&amp;gt;.supabase.co&lt;/code&gt; on Google's screen&lt;/td&gt;
&lt;td&gt;Expected on the default domain. A Supabase custom domain (such as &lt;code&gt;auth.example.com&lt;/code&gt;) shows your own domain instead.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Using it with FastAPI Supabase Starter
&lt;/h2&gt;

&lt;p&gt;Do steps 1 to 4, then list the buttons to show in &lt;code&gt;.env&lt;/code&gt; and rebuild the frontend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OAUTH_PROVIDERS=google

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add more providers the same way (&lt;code&gt;OAUTH_PROVIDERS=google,github&lt;/code&gt;) after enabling each in Supabase. See &lt;a href="https://starter.budeglobal.in/help/sign-in-methods" rel="noopener noreferrer"&gt;Sign-in methods&lt;/a&gt; for email codes, SMS, WhatsApp and CAPTCHA.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>authentication</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Google Search Console and GA4 for a React site, the right way</title>
      <dc:creator>Aravind Govindhasamy</dc:creator>
      <pubDate>Wed, 28 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/aravind-govindhasamy/google-search-console-and-ga4-for-a-react-site-the-right-way-1ng6</link>
      <guid>https://dev.to/aravind-govindhasamy/google-search-console-and-ga4-for-a-react-site-the-right-way-1ng6</guid>
      <description>&lt;p&gt;A React app can rank in Google, but only if Google gets real HTML for your public pages. This guide covers that first, then Search Console (what Google sees), then Google Analytics 4 (what visitors do), with two pitfalls specific to apps that also have sign-in.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Give crawlers real HTML
&lt;/h2&gt;

&lt;p&gt;A classic single-page app serves the same empty &lt;code&gt;index.html&lt;/code&gt; for every URL and fills it in with JavaScript. Google can render JavaScript, but slowly and not always; other search engines, link previews (WhatsApp, LinkedIn, X) and AI crawlers mostly don't.&lt;/p&gt;

&lt;p&gt;Pre-render the public pages (home, pricing, blog, docs) to HTML at build time, each with its own &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;, description, canonical link and Open Graph tags, and keep the signed-in app as a single-page app. Frameworks like TanStack Start, Astro or Next.js do this. Then generate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;sitemap.xml&lt;/code&gt; listing every public page&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;robots.txt&lt;/code&gt; that allows crawling and points to the sitemap&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Verify your site in Search Console
&lt;/h2&gt;

&lt;p&gt;Go to &lt;a href="https://search.google.com/search-console" rel="noopener noreferrer"&gt;search.google.com/search-console&lt;/a&gt; and &lt;strong&gt;Add property&lt;/strong&gt;. Two kinds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Domain&lt;/strong&gt; (&lt;code&gt;example.com&lt;/code&gt;): covers every subdomain and protocol. Verify with a DNS TXT record. On Cloudflare, Google can add it for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;URL prefix&lt;/strong&gt; (&lt;code&gt;https://example.com/&lt;/code&gt;): covers just that address. Verify by uploading an HTML file Google gives you (put it in your frontend's &lt;code&gt;public/&lt;/code&gt; folder so every build includes it), a meta tag, or Google Analytics.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep the verification file or record forever: Google re-checks, and removing it loses access.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Submit the sitemap and request indexing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sitemaps:&lt;/strong&gt; submit &lt;code&gt;https://example.com/sitemap.xml&lt;/code&gt;. Search Console shows how many URLs it found.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;URL Inspection:&lt;/strong&gt; paste your most important URLs (home, pricing) and click &lt;strong&gt;Request indexing&lt;/strong&gt;. There's a small daily quota, so use it on pages that matter.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;First data appears in two or three days; a new site can take a few weeks to be fully indexed. Watch &lt;strong&gt;Pages&lt;/strong&gt; for URLs Google found but didn't index, and why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Also add Bing Webmaster Tools&lt;/strong&gt; (bing.com/webmasters): it imports your Search Console site in a minute, and Bing's index feeds several AI search products.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Add Google Analytics 4
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://analytics.google.com" rel="noopener noreferrer"&gt;analytics.google.com&lt;/a&gt;, create a property and a &lt;strong&gt;Web&lt;/strong&gt; data stream for your domain. Copy the &lt;strong&gt;Measurement ID&lt;/strong&gt; (&lt;code&gt;G-XXXXXXXXXX&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Keep &lt;strong&gt;Enhanced measurement&lt;/strong&gt; on: its "page changes based on browser history events" option counts client-side navigation in a single-page app as page views, so you don't need router code.&lt;/p&gt;

&lt;p&gt;Load the tag from your public pages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;loaded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loadAnalytics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loaded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="nx"&gt;loaded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;dataLayer&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dataLayer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataLayer&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;gtag&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;_args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;dataLayer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// gtag.js expects the arguments object, not an array&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;gtag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="nf"&gt;gtag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;config&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;script&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;script&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://www.googletagmanager.com/gtag/js?id=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the ID in configuration, not in code, if other people or projects use the same codebase.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 1: sign-in tokens in the URL
&lt;/h2&gt;

&lt;p&gt;Some auth flows (Supabase's implicit flow, for example) return the user to your site with &lt;code&gt;#access_token=...&lt;/code&gt; in the address. If analytics loads on that page, the token can end up in Google's reports. Skip loading analytics when the URL carries a token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;access_token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nf"&gt;loadAnalytics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;GA_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Pitfall 2: another tag already created &lt;code&gt;dataLayer&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Cloudflare's Google tag gateway, a Tag Manager container or a plugin may create &lt;code&gt;window.dataLayer&lt;/code&gt; before your code runs. Don't treat "dataLayer exists" as "analytics is loaded", or your tag never loads; track your own loaded flag and share the existing array, as in the snippet above. And if a Tag Manager container already fires your GA4 tag, don't load it twice, or every visit counts double.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Check it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;GA4 &lt;strong&gt;Reports → Realtime&lt;/strong&gt; : open your site and see yourself within a minute.&lt;/li&gt;
&lt;li&gt;Search Console &lt;strong&gt;Sitemaps&lt;/strong&gt; : status "Success".&lt;/li&gt;
&lt;li&gt;Share a page link in a chat app: the preview shows its own title, description and image.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have visitors from the EU, Google Analytics' cookies need consent there. Cookie-free alternatives such as Cloudflare Web Analytics avoid the banner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using it with FastAPI Supabase Starter
&lt;/h2&gt;

&lt;p&gt;The public site is already pre-rendered, with per-page titles, descriptions, share images, structured data, &lt;code&gt;sitemap.xml&lt;/code&gt;, &lt;code&gt;robots.txt&lt;/code&gt; and an RSS feed. Set &lt;code&gt;GA_MEASUREMENT_ID&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt; and rebuild: the tag loads on public pages only, never on a URL carrying a session, and shares any existing &lt;code&gt;dataLayer&lt;/code&gt;. Put Google's verification file in &lt;code&gt;frontend/public/&lt;/code&gt;. See &lt;a href="https://starter.budeglobal.in/help/blog-and-help-pages" rel="noopener noreferrer"&gt;Blog and help pages&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>analytics</category>
      <category>react</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Get a Gemini API key and call it from FastAPI</title>
      <dc:creator>Aravind Govindhasamy</dc:creator>
      <pubDate>Mon, 26 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/aravind-govindhasamy/get-a-gemini-api-key-and-call-it-from-fastapi-2bpk</link>
      <guid>https://dev.to/aravind-govindhasamy/get-a-gemini-api-key-and-call-it-from-fastapi-2bpk</guid>
      <description>&lt;p&gt;Google's Gemini models are fast, capable and have a free tier for development, which makes them a good first model for an in-app assistant. This guide gets a key, keeps it safe, and calls Gemini from FastAPI, including letting the model call your own Python functions.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Create the key in Google AI Studio
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;a href="https://aistudio.google.com/apikey" rel="noopener noreferrer"&gt;aistudio.google.com/apikey&lt;/a&gt; and sign in with a Google account.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Create API key&lt;/strong&gt;. New accounts get a default Google Cloud project; if you already use Google Cloud, choose &lt;strong&gt;Import projects&lt;/strong&gt; and pick the project that should own the key and its billing.&lt;/li&gt;
&lt;li&gt;Copy the key into your &lt;strong&gt;server's&lt;/strong&gt; environment:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GEMINI_API_KEY=...

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every key belongs to a Google Cloud project, which is where billing, quotas and limits live. Before you launch, read Google's current free-tier limits and data-use terms: Google has treated free-tier and paid-tier prompts differently, so turn on billing before real users' data goes through it.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Keep it on the server
&lt;/h2&gt;

&lt;p&gt;Never put the key in frontend code or a mobile app; anyone can read it there and spend your quota. The browser talks to &lt;strong&gt;your&lt;/strong&gt; API, and your API talks to Gemini. That also lets you check who's asking and apply per-user limits.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Call Gemini from FastAPI
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;uv&lt;/span&gt; &lt;span class="n"&gt;add&lt;/span&gt; &lt;span class="n"&gt;google&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;genai&lt;/span&gt;


&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;genai&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;genai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;# reads GEMINI_API_KEY from the environment
&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;

&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/assistant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;assistant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Ask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;CurrentUser&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;aio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate_content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini-2.5-flash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;contents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reply&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use the async client (&lt;code&gt;client.aio&lt;/code&gt;) so a slow model call doesn't block other requests. Check Google's models page for the current model names; &lt;code&gt;flash&lt;/code&gt; models are the fast, inexpensive default.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Let the model call your functions
&lt;/h2&gt;

&lt;p&gt;Tool calling turns a chatbot into an assistant that can look things up. With the SDK, a typed Python function with a docstring becomes a tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.genai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;types&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;list_items&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;List the signed-in user&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s most recent items.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fetch_items_for_current_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;aio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate_content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini-2.5-flash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;contents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;What did I add this week?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GenerateContentConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;list_items&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SDK runs the function when the model asks for it and sends the result back. Two rules keep this safe:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tools run as the user.&lt;/strong&gt; Query with the user's own credentials so the model can never reach other people's data. With Supabase, pass the user's token so row-level security applies. &lt;a href="https://dev.to/aravind_govindhasamy/supabase-api-keys-explained-publishable-secret-and-jwt-signing-keys-for-fastapi-1320-temp-slug-8568848"&gt;How&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tools are read-only or additive.&lt;/strong&gt; The model acts without asking for confirmation, so don't give it a "delete everything" tool.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. Put limits on it
&lt;/h2&gt;

&lt;p&gt;AI calls cost money. Count requests per user per day and refuse past a plan's limit with a &lt;code&gt;429&lt;/code&gt;. Store conversations if users expect history, and log token usage so you can see costs per user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using it with FastAPI Supabase Starter
&lt;/h2&gt;

&lt;p&gt;The assistant page, saved conversations, tool calling and per-plan daily and monthly limits are built in. Set &lt;code&gt;GEMINI_API_KEY&lt;/code&gt; (and optionally &lt;code&gt;GEMINI_MODEL&lt;/code&gt;, default &lt;code&gt;gemini-2.5-flash&lt;/code&gt;), and the same tools are served over MCP so Claude and Cursor can use your app as the signed-in user. Add a tool by writing a typed function in &lt;code&gt;backend/app/ai/tools.py&lt;/code&gt; and appending it to &lt;code&gt;TOOLS&lt;/code&gt;. See &lt;a href="https://starter.budeglobal.in/help/ai-assistant-and-mcp" rel="noopener noreferrer"&gt;AI assistant and MCP&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>gemini</category>
      <category>fastapi</category>
      <category>python</category>
    </item>
    <item>
      <title>Razorpay API keys and webhooks: from test mode to live payments</title>
      <dc:creator>Aravind Govindhasamy</dc:creator>
      <pubDate>Mon, 26 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/aravind-govindhasamy/razorpay-api-keys-and-webhooks-from-test-mode-to-live-payments-5769</link>
      <guid>https://dev.to/aravind-govindhasamy/razorpay-api-keys-and-webhooks-from-test-mode-to-live-payments-5769</guid>
      <description>&lt;p&gt;Razorpay is the easiest way to accept UPI, cards and netbanking in India. This guide gets you from a new account to verified payments: keys, an order created on your server, Razorpay Checkout in the browser, and a webhook that confirms the payment.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the pieces fit
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Your API creates an &lt;strong&gt;order&lt;/strong&gt; with the amount (server-side, so the browser can't change the price).&lt;/li&gt;
&lt;li&gt;The browser opens &lt;strong&gt;Razorpay Checkout&lt;/strong&gt; with that order's id. The customer pays by UPI, card or netbanking.&lt;/li&gt;
&lt;li&gt;Razorpay calls your &lt;strong&gt;webhook&lt;/strong&gt; with &lt;code&gt;order.paid&lt;/code&gt;. Your API checks the signature and marks the payment paid.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The browser's "payment succeeded" callback is only a hint for the UI. The signed webhook is the only thing you should trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Generate test keys
&lt;/h2&gt;

&lt;p&gt;In the Razorpay Dashboard, switch to &lt;strong&gt;Test mode&lt;/strong&gt; , then go to &lt;strong&gt;Account &amp;amp; Settings → API Keys&lt;/strong&gt; (under &lt;strong&gt;Website and app settings&lt;/strong&gt; ) and click &lt;strong&gt;Generate Key&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;You get a &lt;strong&gt;Key ID&lt;/strong&gt; (starts &lt;code&gt;rzp_test_&lt;/code&gt;, safe to send to the browser) and a &lt;strong&gt;Key Secret&lt;/strong&gt; (server only). The dashboard won't show the secret again, so store it right away.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RAZORPAY_KEY_ID=rzp_test_...
RAZORPAY_KEY_SECRET=...

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test mode is a full simulation: no real money moves, and Razorpay provides test cards and UPI ids.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Create an order from FastAPI
&lt;/h2&gt;

&lt;p&gt;Amounts are in paise: ₹1,499.00 is &lt;code&gt;149900&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount_paise&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.razorpay.com/v1/orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RAZORPAY_KEY_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RAZORPAY_KEY_SECRET&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;amount_paise&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;currency&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;receipt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;# contains "id": "order_..."
&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look the price up on the server from a product id. Never accept an amount from the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Open Checkout in the browser
&lt;/h2&gt;

&lt;p&gt;Load Razorpay's script and open Checkout with your Key ID and the order id:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;script&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://checkout.razorpay.com/v1/checkout.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;resolve&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;new &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nc"&gt;Razorpay&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RAZORPAY_KEY_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Example&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;showMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Payment received, confirming…&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Add the webhook
&lt;/h2&gt;

&lt;p&gt;Webhooks need a public HTTPS URL; Razorpay won't save a &lt;code&gt;localhost&lt;/code&gt; one. For local testing, expose your API with a tunnel such as &lt;a href="https://starter.budeglobal.in/blog/cloudflare-tunnel-self-host" rel="noopener noreferrer"&gt;Cloudflare Tunnel&lt;/a&gt; or ngrok.&lt;/p&gt;

&lt;p&gt;In the Dashboard (in the same mode as your keys), go to &lt;strong&gt;Account &amp;amp; Settings → Webhooks → Add New Webhook&lt;/strong&gt; :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Webhook URL:&lt;/strong&gt; &lt;code&gt;https://api.example.com/payments/webhooks/razorpay&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secret:&lt;/strong&gt; a long random string you generate; it's not your Key Secret&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alert Email:&lt;/strong&gt; where Razorpay reports failures&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Active Events:&lt;/strong&gt; &lt;code&gt;order.paid&lt;/code&gt; and &lt;code&gt;payment.failed&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RAZORPAY_WEBHOOK_SECRET=...

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set the webhook up again when you switch to live mode, with its own secret.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Verify the signature
&lt;/h2&gt;

&lt;p&gt;Razorpay signs the raw request body with HMAC-SHA256 using the webhook secret and sends the hex digest in &lt;code&gt;X-Razorpay-Signature&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;

&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/payments/webhooks/razorpay&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;razorpay_webhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;# the raw bytes, before any JSON parsing
&lt;/span&gt;    &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RAZORPAY_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compare_digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x-razorpay-signature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bad signature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order.paid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;mark_paid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payload&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="c1"&gt;# idempotent
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Razorpay retries deliveries, so make &lt;code&gt;mark_paid&lt;/code&gt; idempotent: record each event or order id once, and ignore repeats.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Go live
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;In live mode, Razorpay asks for the website where you'll take payments and verifies it (it can take a few working days). Make sure the site has the policy pages Razorpay checks for: terms, privacy, refunds and cancellation, shipping or delivery, and contact details.&lt;/li&gt;
&lt;li&gt;Generate live keys (&lt;code&gt;rzp_live_...&lt;/code&gt;) and a live webhook with a new secret.&lt;/li&gt;
&lt;li&gt;Swap the three values in production and make a small real payment to check.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Using it with FastAPI Supabase Starter
&lt;/h2&gt;

&lt;p&gt;Steps 2, 3 and 5 are already built: products and prices live on the server, the billing page opens Checkout, and the webhook verifies the signature and applies each event exactly once before upgrading the user's plan. Set the three &lt;code&gt;RAZORPAY_*&lt;/code&gt; values, point the webhook at &lt;code&gt;/payments/webhooks/razorpay&lt;/code&gt;, and the public site already has the policy pages Razorpay asks for. See &lt;a href="https://starter.budeglobal.in/help/payments" rel="noopener noreferrer"&gt;Payments&lt;/a&gt; and &lt;a href="https://dev.to/aravind_govindhasamy/razorpay-and-stripe-in-one-fastapi-app-without-trusting-the-browser-97a-temp-slug-436395"&gt;the payments design&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>python</category>
      <category>fastapi</category>
      <category>stripe</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Deploy FastAPI and React with Docker Compose</title>
      <dc:creator>Aravind Govindhasamy</dc:creator>
      <pubDate>Mon, 26 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/aravind-govindhasamy/deploy-fastapi-and-react-with-docker-compose-2f9g</link>
      <guid>https://dev.to/aravind-govindhasamy/deploy-fastapi-and-react-with-docker-compose-2f9g</guid>
      <description>&lt;p&gt;Docker Compose gives you the same setup on your laptop and your server: one command builds and starts the API and the frontend. This guide covers both Dockerfiles, the one setting people get wrong (build-time variables for the frontend), and going to production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The backend image
&lt;/h2&gt;

&lt;p&gt;With &lt;a href="https://docs.astral.sh/uv/" rel="noopener noreferrer"&gt;uv&lt;/a&gt;, the image is small and the install is reproducible from the lockfile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; python:3.12-slim&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv&lt;/span&gt;

&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; pyproject.toml uv.lock ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;uv &lt;span class="nb"&gt;sync&lt;/span&gt; &lt;span class="nt"&gt;--frozen&lt;/span&gt; &lt;span class="nt"&gt;--no-dev&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; app ./app&lt;/span&gt;

&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 8000&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["/app/.venv/bin/fastapi", "run", "app/main.py", "--port", "8000"]&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copying &lt;code&gt;pyproject.toml&lt;/code&gt; and &lt;code&gt;uv.lock&lt;/code&gt; before the code means dependency layers are cached until the dependencies change. Pin the uv image to a version in production instead of &lt;code&gt;latest&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The frontend image
&lt;/h2&gt;

&lt;p&gt;A React build is static files, so build with Node and serve with nginx:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:24-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;build&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package.json package-lock.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; API_URL SITE_URL&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm run build

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; nginx:alpine&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; nginx.conf /etc/nginx/conf.d/default.conf&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build /app/dist /usr/share/nginx/html&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With an &lt;code&gt;nginx.conf&lt;/code&gt; that falls back to the app for client-side routes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/usr/share/nginx/html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="n"&gt;/index.html&lt;/span&gt; &lt;span class="n"&gt;/index.html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Build-time vs run-time settings
&lt;/h2&gt;

&lt;p&gt;This trips everyone up once. The frontend's settings (the API URL, public keys, analytics ids) are &lt;strong&gt;baked into the JavaScript at build time&lt;/strong&gt;. Setting them as container environment variables does nothing; they must be &lt;strong&gt;build args&lt;/strong&gt; , and you rebuild when they change. The backend reads its settings at run time from the environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  compose.yml
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./backend&lt;/span&gt;
    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.env&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8000:8000"&lt;/span&gt;

  &lt;span class="na"&gt;frontend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./frontend&lt;/span&gt;
      &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;API_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${API_URL:-http://localhost:8000}&lt;/span&gt;
        &lt;span class="na"&gt;SITE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${SITE_URL:-http://localhost:5173}&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5173:80"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compose reads &lt;code&gt;${API_URL}&lt;/code&gt; from your &lt;code&gt;.env&lt;/code&gt; file next to &lt;code&gt;compose.yml&lt;/code&gt;, so one file feeds both.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;--build&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;
docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; backend

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Production
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Set public URLs&lt;/strong&gt; in &lt;code&gt;.env&lt;/code&gt;: &lt;code&gt;API_URL=https://api.example.com&lt;/code&gt;, &lt;code&gt;SITE_URL=https://example.com&lt;/code&gt;, then rebuild (&lt;code&gt;docker compose up --build -d&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put HTTPS in front.&lt;/strong&gt; Bind the ports to localhost (&lt;code&gt;"127.0.0.1:8000:8000"&lt;/code&gt;) and use a reverse proxy on the host: nginx with certbot, Caddy, or &lt;a href="https://starter.budeglobal.in/blog/cloudflare-tunnel-self-host" rel="noopener noreferrer"&gt;Cloudflare Tunnel&lt;/a&gt; with no open ports at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real client IPs.&lt;/strong&gt; If the proxy reaches the API over Docker's network, the API sees the proxy's container address. Set uvicorn's &lt;code&gt;FORWARDED_ALLOW_IPS&lt;/code&gt; to that address (for example the bridge gateway &lt;code&gt;172.18.0.1&lt;/code&gt;) so &lt;code&gt;X-Forwarded-For&lt;/code&gt; is trusted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restart policy.&lt;/strong&gt; Add &lt;code&gt;restart: unless-stopped&lt;/code&gt; to both services.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Updates:&lt;/strong&gt; &lt;code&gt;git pull &amp;amp;&amp;amp; docker compose up --build -d&lt;/code&gt;, then check the health endpoint.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Using it with FastAPI Supabase Starter
&lt;/h2&gt;

&lt;p&gt;The starter ships both Dockerfiles and a &lt;code&gt;compose.yml&lt;/code&gt; with every build arg listed (&lt;code&gt;SUPABASE_URL&lt;/code&gt;, &lt;code&gt;SUPABASE_PUBLISHABLE_KEY&lt;/code&gt;, &lt;code&gt;API_URL&lt;/code&gt;, &lt;code&gt;SITE_URL&lt;/code&gt;, &lt;code&gt;OAUTH_PROVIDERS&lt;/code&gt;, &lt;code&gt;TURNSTILE_SITE_KEY&lt;/code&gt;, &lt;code&gt;SENTRY_DSN&lt;/code&gt;, &lt;code&gt;GA_MEASUREMENT_ID&lt;/code&gt;). Fill in &lt;code&gt;.env&lt;/code&gt; and run &lt;code&gt;docker compose up --build&lt;/code&gt;: the app is on port 5173 and the API on 8000. There's no database container, because Supabase hosts Postgres. See the &lt;a href="https://starter.budeglobal.in/help/quick-start" rel="noopener noreferrer"&gt;quick start&lt;/a&gt; and &lt;a href="https://starter.budeglobal.in/help/deploying" rel="noopener noreferrer"&gt;Deploy to production&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>fastapi</category>
      <category>react</category>
      <category>devops</category>
    </item>
    <item>
      <title>Deploy FastAPI and a React app on a VPS with nginx, systemd and uv</title>
      <dc:creator>Aravind Govindhasamy</dc:creator>
      <pubDate>Sun, 25 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/aravind-govindhasamy/deploy-fastapi-and-a-react-app-on-a-vps-with-nginx-systemd-and-uv-3ek0</link>
      <guid>https://dev.to/aravind-govindhasamy/deploy-fastapi-and-a-react-app-on-a-vps-with-nginx-systemd-and-uv-3ek0</guid>
      <description>&lt;p&gt;A single small VPS (or a home server) runs a FastAPI + React app comfortably. No containers needed: the frontend is static files, the API is one Python process under systemd, and nginx sits in front of both.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fstarter.budeglobal.in%2Fblog%2Fdeploy-architecture.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fstarter.budeglobal.in%2Fblog%2Fdeploy-architecture.svg" alt="Browsers reach nginx, which serves the frontend's static files and proxies API requests to uvicorn; uvicorn calls Supabase" width="960" height="380"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What you need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An Ubuntu server (22.04 or newer) you can SSH into&lt;/li&gt;
&lt;li&gt;A domain with two DNS records pointing at the server: &lt;code&gt;example.com&lt;/code&gt; (the site) and &lt;code&gt;api.example.com&lt;/code&gt; (the API)&lt;/li&gt;
&lt;li&gt;Your app's code, with the backend managed by &lt;a href="https://docs.astral.sh/uv/" rel="noopener noreferrer"&gt;uv&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Prepare the server
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; nginx
curl &lt;span class="nt"&gt;-LsSf&lt;/span&gt; https://astral.sh/uv/install.sh | sh &lt;span class="c"&gt;# installs uv for your user&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open only what you need with the firewall:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow OpenSSH &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow &lt;span class="s1"&gt;'Nginx Full'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;ufw &lt;span class="nb"&gt;enable&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Build the frontend where you develop
&lt;/h2&gt;

&lt;p&gt;Build on your own machine (or in CI), not on the server: the server doesn't need Node, and a build failure never takes the site down.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;frontend
&lt;span class="nv"&gt;API_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://api.example.com &lt;span class="nv"&gt;SITE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://example.com npm run build

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy the output to the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-czf&lt;/span&gt; - &lt;span class="nt"&gt;-C&lt;/span&gt; dist &lt;span class="nb"&gt;.&lt;/span&gt; | ssh user@server &lt;span class="s1"&gt;'sudo mkdir -p /var/www/example &amp;amp;&amp;amp; sudo tar -xzf - -C /var/www/example'&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(If your build writes to a subfolder, such as &lt;code&gt;dist/client&lt;/code&gt; for a pre-rendered TanStack Start site, copy that folder instead.)&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Install the API
&lt;/h2&gt;

&lt;p&gt;Copy the backend to the server (for example with &lt;code&gt;git archive&lt;/code&gt;), then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/app/backend
uv &lt;span class="nb"&gt;sync&lt;/span&gt; &lt;span class="nt"&gt;--frozen&lt;/span&gt; &lt;span class="nt"&gt;--no-dev&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put secrets in &lt;code&gt;~/app/.env&lt;/code&gt; with permissions &lt;code&gt;600&lt;/code&gt;. Then create &lt;code&gt;/etc/systemd/system/example-api.service&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight systemd"&gt;&lt;code&gt;&lt;span class="k"&gt;[Unit]&lt;/span&gt;
&lt;span class="nt"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;Example API
&lt;span class="nt"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;network-online.target

&lt;span class="k"&gt;[Service]&lt;/span&gt;
&lt;span class="nt"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;deploy
&lt;span class="nt"&gt;WorkingDirectory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;/home/deploy/app/backend
&lt;span class="nt"&gt;EnvironmentFile&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;/home/deploy/app/.env
&lt;span class="nt"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;/home/deploy/app/backend/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 --workers 2 --proxy-headers --forwarded-allow-ips 127.0.0.1
&lt;span class="nt"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;always

&lt;span class="k"&gt;[Install]&lt;/span&gt;
&lt;span class="nt"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;multi-user.target


&lt;span class="err"&gt;sudo&lt;/span&gt; &lt;span class="err"&gt;systemctl&lt;/span&gt; &lt;span class="err"&gt;daemon-reload&lt;/span&gt; &lt;span class="err"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="err"&gt;sudo&lt;/span&gt; &lt;span class="err"&gt;systemctl&lt;/span&gt; &lt;span class="err"&gt;enable&lt;/span&gt; &lt;span class="err"&gt;--now&lt;/span&gt; &lt;span class="err"&gt;example-api&lt;/span&gt;
&lt;span class="err"&gt;curl&lt;/span&gt; &lt;span class="err"&gt;http://127.0.0.1:8000/health&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--proxy-headers&lt;/code&gt; with &lt;code&gt;--forwarded-allow-ips 127.0.0.1&lt;/code&gt; makes uvicorn trust nginx's &lt;code&gt;X-Forwarded-For&lt;/code&gt;, so your logs and rate limits see real client IPs, not &lt;code&gt;127.0.0.1&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Configure nginx
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/etc/nginx/sites-available/example&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www/example&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/assets/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;expires&lt;/span&gt; &lt;span class="s"&gt;1y&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Cache-Control&lt;/span&gt; &lt;span class="s"&gt;"public,&lt;/span&gt; &lt;span class="s"&gt;immutable"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Cache-Control&lt;/span&gt; &lt;span class="s"&gt;"no-cache"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="n"&gt;/index.html&lt;/span&gt; &lt;span class="n"&gt;/index.html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;api.example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:8000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;try_files&lt;/code&gt; line serves a page's own HTML when it exists (pre-rendered pages) and falls back to the single-page app for every other path. Use your SPA's shell file in place of &lt;code&gt;/index.html&lt;/code&gt; if it has a different name.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo ln&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /etc/nginx/sites-available/example /etc/nginx/sites-enabled/
&lt;span class="nb"&gt;sudo &lt;/span&gt;nginx &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload nginx

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. HTTPS
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; certbot python3-certbot-nginx
&lt;span class="nb"&gt;sudo &lt;/span&gt;certbot &lt;span class="nt"&gt;--nginx&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; example.com &lt;span class="nt"&gt;-d&lt;/span&gt; api.example.com

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Certbot edits the nginx config and renews certificates automatically. Behind Cloudflare, or with no public IP at all, use &lt;a href="https://starter.budeglobal.in/blog/cloudflare-tunnel-self-host" rel="noopener noreferrer"&gt;Cloudflare Tunnel&lt;/a&gt; instead and skip this step.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Redeploy
&lt;/h2&gt;

&lt;p&gt;A redeploy is the same three moves: build and copy the frontend, copy the backend and &lt;code&gt;uv sync&lt;/code&gt;, restart the service.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart example-api &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; https://api.example.com/health

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;.env&lt;/code&gt; is readable only by the service user, and never committed.&lt;/li&gt;
&lt;li&gt;The API listens on &lt;code&gt;127.0.0.1&lt;/code&gt; only; nginx is the only public entry.&lt;/li&gt;
&lt;li&gt;An uptime monitor watches &lt;code&gt;/health&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Logs: &lt;code&gt;journalctl -u example-api -f&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Using it with FastAPI Supabase Starter
&lt;/h2&gt;

&lt;p&gt;This is how the starter's own demo runs. Build with &lt;code&gt;API_URL&lt;/code&gt; and &lt;code&gt;SITE_URL&lt;/code&gt;, copy &lt;code&gt;frontend/dist/client&lt;/code&gt;, and use &lt;code&gt;/_shell.html&lt;/code&gt; as the fallback in &lt;code&gt;try_files&lt;/code&gt;. The API needs no database password: it talks to Supabase over HTTPS. Webhook and hook URLs to register afterwards are listed in &lt;a href="https://starter.budeglobal.in/help/deploying" rel="noopener noreferrer"&gt;Deploy to production&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>fastapi</category>
      <category>react</category>
      <category>nginx</category>
      <category>devops</category>
    </item>
    <item>
      <title>Cloudflare Tunnel: publish a home server or VPS without opening ports</title>
      <dc:creator>Aravind Govindhasamy</dc:creator>
      <pubDate>Thu, 22 Oct 2026 15:52:06 +0000</pubDate>
      <link>https://dev.to/aravind-govindhasamy/cloudflare-tunnel-publish-a-home-server-or-vps-without-opening-ports-4cbc</link>
      <guid>https://dev.to/aravind-govindhasamy/cloudflare-tunnel-publish-a-home-server-or-vps-without-opening-ports-4cbc</guid>
      <description>&lt;p&gt;Cloudflare Tunnel lets a machine that can't accept incoming connections (a home server behind your router, a laptop, a VPS with every port closed) serve websites on your domain. A small program, &lt;code&gt;cloudflared&lt;/code&gt;, opens an outbound connection to Cloudflare, and Cloudflare sends your visitors' requests down it. You get HTTPS, DDoS protection and no open ports, for free.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fstarter.budeglobal.in%2Fblog%2Fcloudflare-tunnel.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fstarter.budeglobal.in%2Fblog%2Fcloudflare-tunnel.svg" alt="Visitors connect to Cloudflare over HTTPS; cloudflared on your server keeps an outbound tunnel to Cloudflare and forwards each hostname to a local port" width="960" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What you need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A domain using Cloudflare's nameservers (add it to Cloudflare and change the nameservers at your registrar)&lt;/li&gt;
&lt;li&gt;A Linux machine running your app on local ports, for example a website on &lt;code&gt;localhost:8080&lt;/code&gt; and an API on &lt;code&gt;localhost:8000&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Create the tunnel
&lt;/h2&gt;

&lt;p&gt;In the Cloudflare dashboard, go to &lt;strong&gt;Networking → Tunnels&lt;/strong&gt; (in the Zero Trust dashboard it's &lt;strong&gt;Networks → Tunnels&lt;/strong&gt; ) and click &lt;strong&gt;Create a tunnel&lt;/strong&gt;. Choose &lt;strong&gt;cloudflared&lt;/strong&gt; , give it a name such as &lt;code&gt;home-server&lt;/code&gt;, and save.&lt;/p&gt;

&lt;p&gt;Cloudflare shows install commands for your operating system. On Debian or Ubuntu they add Cloudflare's package repository, install &lt;code&gt;cloudflared&lt;/code&gt;, and register it as a service with your tunnel's token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;cloudflared service &lt;span class="nb"&gt;install&lt;/span&gt; &amp;lt;TOKEN&amp;gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Within a few seconds the dashboard shows the connector as &lt;strong&gt;Connected&lt;/strong&gt;. The token is a credential: anyone with it can run your tunnel, so don't paste it in public.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Route a hostname to a local port
&lt;/h2&gt;

&lt;p&gt;Open the tunnel, go to &lt;strong&gt;Routes&lt;/strong&gt; , click &lt;strong&gt;Add route&lt;/strong&gt; and choose &lt;strong&gt;Published application&lt;/strong&gt; (older dashboards call this &lt;strong&gt;Public hostnames → Add a public hostname&lt;/strong&gt; ):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Subdomain&lt;/td&gt;
&lt;td&gt;&lt;code&gt;app&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Domain&lt;/td&gt;
&lt;td&gt;&lt;code&gt;example.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Path&lt;/td&gt;
&lt;td&gt;(empty)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service URL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://localhost:8080&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Save. Cloudflare creates the DNS record for &lt;code&gt;app.example.com&lt;/code&gt; itself (a proxied CNAME to your tunnel), and the site is live over HTTPS. No restart needed.&lt;/p&gt;

&lt;p&gt;Add as many hostnames as you like to one tunnel, for example &lt;code&gt;api.example.com → http://localhost:8000&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. See real visitor IPs
&lt;/h2&gt;

&lt;p&gt;Requests reach your app from &lt;code&gt;cloudflared&lt;/code&gt; on the same machine, so your app sees &lt;code&gt;127.0.0.1&lt;/code&gt; as every visitor's address. Cloudflare passes the real one in &lt;code&gt;X-Forwarded-For&lt;/code&gt; and &lt;code&gt;CF-Connecting-IP&lt;/code&gt;. Tell your server to trust them from localhost:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;uvicorn (FastAPI):&lt;/strong&gt; &lt;code&gt;--proxy-headers --forwarded-allow-ips 127.0.0.1&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;nginx:&lt;/strong&gt; &lt;code&gt;set_real_ip_from 127.0.0.1; real_ip_header CF-Connecting-IP;&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without this, per-IP rate limits treat all your visitors as one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dashboard tunnels ignore local ingress rules.&lt;/strong&gt; If you created the tunnel in the dashboard, editing &lt;code&gt;/etc/cloudflared/config.yml&lt;/code&gt; changes nothing: the routes come from the dashboard. Manage hostnames there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restarting &lt;code&gt;cloudflared&lt;/code&gt; drops every hostname on the tunnel&lt;/strong&gt; for a few seconds. You don't need to restart it to add or change routes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep your firewall closed.&lt;/strong&gt; The tunnel is outbound, so &lt;code&gt;ufw default deny incoming&lt;/code&gt; stays as it is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webhooks work.&lt;/strong&gt; Stripe, Razorpay and Supabase hooks reach a tunneled API like any HTTPS URL, which also makes a tunnel handy for testing webhooks from your laptop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare settings apply.&lt;/strong&gt; Caching rules, Bot Fight Mode and Cloudflare's managed &lt;code&gt;robots.txt&lt;/code&gt; affect tunneled sites like any other. If search engines or AI crawlers can't see your site, check those first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Protect private tools.&lt;/strong&gt; Put admin hostnames behind &lt;strong&gt;Cloudflare Access&lt;/strong&gt; so only your email can reach them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Check it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-I&lt;/span&gt; https://app.example.com &lt;span class="c"&gt;# should show server: cloudflare&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl status cloudflared &lt;span class="c"&gt;# connector running&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Using it with FastAPI Supabase Starter
&lt;/h2&gt;

&lt;p&gt;Serve the built frontend (for example with nginx on a local port) and run the API on &lt;code&gt;127.0.0.1:8000&lt;/code&gt;, then add two routes: your site's hostname to the frontend's port and &lt;code&gt;api.&lt;/code&gt; to port 8000. Set &lt;code&gt;API_URL&lt;/code&gt;, &lt;code&gt;FRONTEND_URL&lt;/code&gt; and &lt;code&gt;SITE_URL&lt;/code&gt; to the public HTTPS addresses, and keep &lt;code&gt;FORWARDED_ALLOW_IPS&lt;/code&gt; at its localhost default so the request log and rate limit see real IPs. The full server setup is in &lt;a href="https://dev.to/aravind_govindhasamy/deploy-fastapi-and-a-react-app-on-a-vps-with-nginx-systemd-and-uv-100e-temp-slug-7151020"&gt;Deploy FastAPI and React on a VPS&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>python</category>
      <category>fastapi</category>
      <category>supabase</category>
      <category>security</category>
    </item>
    <item>
      <title>Supabase API keys explained: publishable, secret and JWT signing keys for FastAPI</title>
      <dc:creator>Aravind Govindhasamy</dc:creator>
      <pubDate>Thu, 22 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/aravind-govindhasamy/supabase-api-keys-explained-publishable-secret-and-jwt-signing-keys-for-fastapi-49lm</link>
      <guid>https://dev.to/aravind-govindhasamy/supabase-api-keys-explained-publishable-secret-and-jwt-signing-keys-for-fastapi-49lm</guid>
      <description>&lt;p&gt;Supabase gives every project a URL and a handful of keys, and mixing them up is the most common way to leak data. This guide covers what each one is, where it belongs, and how a FastAPI backend should use them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The keys at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Key&lt;/th&gt;
&lt;th&gt;Looks like&lt;/th&gt;
&lt;th&gt;Where it goes&lt;/th&gt;
&lt;th&gt;Row-level security&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Project URL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://&amp;lt;project-ref&amp;gt;.supabase.co&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Browser and server&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Publishable key&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sb_publishable_...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Browser, mobile apps, and your server&lt;/td&gt;
&lt;td&gt;Applies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secret key&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sb_secret_...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Server only, never a browser&lt;/td&gt;
&lt;td&gt;Bypassed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Legacy &lt;code&gt;anon&lt;/code&gt; key&lt;/td&gt;
&lt;td&gt;A long JWT starting &lt;code&gt;eyJ&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Same as publishable&lt;/td&gt;
&lt;td&gt;Applies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Legacy &lt;code&gt;service_role&lt;/code&gt; key&lt;/td&gt;
&lt;td&gt;A long JWT starting &lt;code&gt;eyJ&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Same as secret&lt;/td&gt;
&lt;td&gt;Bypassed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The legacy &lt;code&gt;anon&lt;/code&gt; and &lt;code&gt;service_role&lt;/code&gt; keys still work, but Supabase is retiring them by the end of 2026. Use the publishable and secret keys in new code.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Find your URL and keys
&lt;/h2&gt;

&lt;p&gt;In the Supabase dashboard, open your project and go to &lt;strong&gt;Project Settings → API Keys&lt;/strong&gt;. The &lt;strong&gt;Connect&lt;/strong&gt; button at the top of the project also shows the URL and publishable key.&lt;/p&gt;

&lt;p&gt;Put them in your backend's environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SUPABASE_URL=https://&amp;lt;project-ref&amp;gt;.supabase.co
SUPABASE_PUBLISHABLE_KEY=sb_publishable_...
SUPABASE_SECRET_KEY=sb_secret_...

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The frontend only ever gets the URL and the publishable key.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Know what the secret key can do
&lt;/h2&gt;

&lt;p&gt;The secret key skips row-level security: any query made with it can read and change every row. Supabase refuses it from a browser (it answers 401), but that check isn't protection on its own. Keep it in server environment variables only, and use it only for work no user owns, such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;recording a payment after a verified webhook&lt;/li&gt;
&lt;li&gt;looking up an API key's owner&lt;/li&gt;
&lt;li&gt;sending a message on the system's behalf&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For everything a user asks for, call Supabase &lt;strong&gt;as that user&lt;/strong&gt; (step 4).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tip:&lt;/strong&gt; create a separate secret key for each backend component (API, workers, scripts). If one leaks, you rotate that one: create a new key under &lt;strong&gt;API Keys&lt;/strong&gt; , deploy it, confirm it works, then delete the old one.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Verify the user's token in FastAPI
&lt;/h2&gt;

&lt;p&gt;When a user signs in, Supabase Auth gives the browser an access token (a JWT). The browser sends it to your API as &lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt;. Your API must check it before trusting it.&lt;/p&gt;

&lt;p&gt;Supabase signs tokens with &lt;strong&gt;JWT signing keys&lt;/strong&gt;. With asymmetric keys (ES256 or RS256), the public keys are published at a JWKS endpoint, so your API can verify tokens without any secret:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://&amp;lt;project-ref&amp;gt;.supabase.co/auth/v1/.well-known/jwks.json

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With PyJWT:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;

&lt;span class="n"&gt;jwks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PyJWKClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;SUPABASE_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/auth/v1/.well-known/jwks.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cache_keys&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;alg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_unverified_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;alg&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ES256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RS256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;InvalidAlgorithmError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_signing_key_from_jwt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;algorithms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;alg&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;audience&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;authenticated&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;require&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]})&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Older projects may still sign with the legacy shared &lt;strong&gt;JWT secret&lt;/strong&gt; (HS256). If yours does, verify with that secret instead, or migrate to signing keys on the &lt;strong&gt;JWT Keys&lt;/strong&gt; page of your project settings, where Supabase moves keys through standby, current and revoked states so you can rotate in stages. Always pin the algorithm: never accept whatever the token's header claims.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Call Supabase as the user
&lt;/h2&gt;

&lt;p&gt;The safest pattern is to pass the user's own token through to Supabase. Postgres then sees who is asking, and your row-level security policies decide what they can read or write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;postgrest&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SyncPostgrestClient&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;as_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;SyncPostgrestClient&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;SyncPostgrestClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;SUPABASE_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/rest/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;apikey&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SUPABASE_PUBLISHABLE_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;as_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;from_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="c1"&gt;# only this user's rows
&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare that with connecting to Postgres directly or using the secret key for everything: then every route has to remember to filter by owner, and one forgotten &lt;code&gt;where user_id = ...&lt;/code&gt; leaks other people's data. &lt;a href="https://dev.to/aravind_govindhasamy/why-our-fastapi-backend-never-connects-to-postgres-directly-3hhh-temp-slug-2831773"&gt;Why we never connect to Postgres directly&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The browser has the URL and publishable key, nothing else.&lt;/li&gt;
&lt;li&gt;The secret key lives in server environment variables and is used only for system work.&lt;/li&gt;
&lt;li&gt;Your API verifies every token with the JWKS endpoint and a pinned algorithm.&lt;/li&gt;
&lt;li&gt;User requests reach Supabase with the user's own token, so RLS applies.&lt;/li&gt;
&lt;li&gt;Every table has RLS enabled, with policies written for &lt;code&gt;auth.uid()&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Using it with FastAPI Supabase Starter
&lt;/h2&gt;

&lt;p&gt;The starter is built this way. Copy the three values above into &lt;code&gt;.env&lt;/code&gt; and you're done: &lt;code&gt;verify_token()&lt;/code&gt; checks tokens against your project's JWKS (with the legacy secret as a fallback), every route queries through &lt;code&gt;as_user(token)&lt;/code&gt;, and the secret key is used only for verified webhooks, payments and API-key lookups. API keys and MCP clients get a real Supabase session for their owner, so the same RLS applies to them. Setup steps are in the &lt;a href="https://starter.budeglobal.in/help/quick-start" rel="noopener noreferrer"&gt;quick start&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>fastapi</category>
      <category>python</category>
      <category>security</category>
    </item>
    <item>
      <title>Send Supabase Auth emails through Resend SMTP (with Cloudflare DNS)</title>
      <dc:creator>Aravind Govindhasamy</dc:creator>
      <pubDate>Thu, 22 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/aravind-govindhasamy/send-supabase-auth-emails-through-resend-smtp-with-cloudflare-dns-56bh</link>
      <guid>https://dev.to/aravind-govindhasamy/send-supabase-auth-emails-through-resend-smtp-with-cloudflare-dns-56bh</guid>
      <description>&lt;p&gt;Out of the box, Supabase Auth sends confirmation, magic-link and password-reset emails through a shared service meant for testing. It only delivers to members of your Supabase team, and only about 2 messages an hour. The first real user who signs up won't get their email. The fix is custom SMTP, and &lt;a href="https://resend.com" rel="noopener noreferrer"&gt;Resend&lt;/a&gt; is a quick, developer-friendly option.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Resend account&lt;/li&gt;
&lt;li&gt;A domain you control, with DNS on Cloudflare (other DNS hosts work the same way)&lt;/li&gt;
&lt;li&gt;Your Supabase project&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Pick a sending domain
&lt;/h2&gt;

&lt;p&gt;Send from a subdomain, such as &lt;code&gt;mail.example.com&lt;/code&gt; or &lt;code&gt;auth.example.com&lt;/code&gt;, rather than your root domain. It keeps your sending reputation separate from your main domain's mail, and Supabase's own guidance recommends a separate domain for auth emails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out:&lt;/strong&gt; the address you send from must be on the domain you verify. If you verify &lt;code&gt;mail.example.com&lt;/code&gt;, send from &lt;code&gt;no-reply@mail.example.com&lt;/code&gt;. Sending from &lt;code&gt;no-reply@example.com&lt;/code&gt; fails with a "domain is not verified" error.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Add the domain in Resend
&lt;/h2&gt;

&lt;p&gt;In Resend, go to &lt;strong&gt;Domains → Add domain&lt;/strong&gt; and enter your sending domain. Resend shows the DNS records to add.&lt;/p&gt;

&lt;p&gt;On Cloudflare, Resend's &lt;strong&gt;Sign in to Cloudflare&lt;/strong&gt; button can add them for you. To add them by hand, open your zone's &lt;strong&gt;DNS → Records&lt;/strong&gt; and create:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Content&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;MX&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;send&lt;/code&gt; (or &lt;code&gt;send.mail&lt;/code&gt; for a subdomain)&lt;/td&gt;
&lt;td&gt;the &lt;code&gt;feedback-smtp...amazonses.com&lt;/code&gt; value Resend shows&lt;/td&gt;
&lt;td&gt;Priority 10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TXT&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;send&lt;/code&gt; (or &lt;code&gt;send.mail&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;v=spf1 include:amazonses.com ~all&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SPF&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TXT&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;resend._domainkey&lt;/code&gt; (or &lt;code&gt;resend._domainkey.mail&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;the long &lt;code&gt;p=...&lt;/code&gt; value Resend shows&lt;/td&gt;
&lt;td&gt;DKIM, &lt;strong&gt;DNS only&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Copy the exact names Resend gives you: for a subdomain they include it. In the Name field, enter only the part before your zone's domain; Cloudflare adds the rest.&lt;/p&gt;

&lt;p&gt;Then click &lt;strong&gt;Verify&lt;/strong&gt; in Resend. Records usually propagate in minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recommended:&lt;/strong&gt; add a DMARC record too, for example a TXT record named &lt;code&gt;_dmarc&lt;/code&gt; with &lt;code&gt;v=DMARC1; p=none;&lt;/code&gt; to start, then tighten it once mail flows cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Create an API key
&lt;/h2&gt;

&lt;p&gt;In Resend, go to &lt;strong&gt;API Keys → Create API key&lt;/strong&gt;. For SMTP, a key with &lt;strong&gt;Sending access&lt;/strong&gt; is enough. Copy it now; it's shown once.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Turn on custom SMTP in Supabase
&lt;/h2&gt;

&lt;p&gt;In the Supabase dashboard, open &lt;strong&gt;Authentication → Emails → SMTP Settings&lt;/strong&gt; and enable custom SMTP:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sender email&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;no-reply@mail.example.com&lt;/code&gt; (on your verified domain)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sender name&lt;/td&gt;
&lt;td&gt;Your product name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host&lt;/td&gt;
&lt;td&gt;&lt;code&gt;smtp.resend.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Port&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;587&lt;/code&gt; (STARTTLS) or &lt;code&gt;465&lt;/code&gt; (SSL)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Username&lt;/td&gt;
&lt;td&gt;&lt;code&gt;resend&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Password&lt;/td&gt;
&lt;td&gt;your Resend API key (&lt;code&gt;re_...&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Save. Supabase now sends to any address, starting with a limit of 30 emails an hour; raise it under &lt;strong&gt;Authentication → Rate Limits&lt;/strong&gt; once you're sure the setup is right.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Send a test
&lt;/h2&gt;

&lt;p&gt;Sign up with an address outside your team, or trigger a password reset. The email should arrive within seconds, and it shows up in Resend's &lt;strong&gt;Emails&lt;/strong&gt; list with its delivery status. If it doesn't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Domain is not verified":&lt;/strong&gt; the sender address isn't on the verified domain (see step 1).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing in Resend's list:&lt;/strong&gt; the SMTP details in Supabase are wrong; re-enter the password.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lands in spam:&lt;/strong&gt; check SPF and DKIM pass in the message headers, and add DMARC.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  6. Send your app's own emails
&lt;/h2&gt;

&lt;p&gt;Invitations and notifications from your API can use the same SMTP details. In FastAPI with the standard library:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;smtplib&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;email.message&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;EmailMessage&lt;/span&gt;

&lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;EmailMessage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;From&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no-reply@mail.example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;To&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user@example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Subject&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;re invited&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Join the team: https://example.com/invite/abc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;smtplib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SMTP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;smtp.resend.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;587&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;smtp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;smtp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;starttls&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;smtp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resend&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RESEND_API_KEY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;smtp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it in a thread (&lt;code&gt;run_in_threadpool&lt;/code&gt;) or a background task so a slow mail server doesn't block requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using it with FastAPI Supabase Starter
&lt;/h2&gt;

&lt;p&gt;Steps 1 to 5 are the same. For the app's own emails (organization invitations and templates from &lt;strong&gt;Admin › Messaging&lt;/strong&gt; ), put the same values in &lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SMTP_HOST=smtp.resend.com
SMTP_PORT=587
SMTP_USER=resend
SMTP_PASSWORD=re_xxx
EMAILS_FROM_EMAIL=no-reply@mail.example.com

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every message is recorded with its status in the delivery log under &lt;strong&gt;Admin › Messaging&lt;/strong&gt;. More in &lt;a href="https://starter.budeglobal.in/help/email-and-messaging" rel="noopener noreferrer"&gt;Email, SMS and WhatsApp&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>payments</category>
      <category>razorpay</category>
      <category>fastapi</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Stop bot sign-ups on Supabase with Cloudflare Turnstile</title>
      <dc:creator>Aravind Govindhasamy</dc:creator>
      <pubDate>Thu, 22 Oct 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/aravind-govindhasamy/stop-bot-sign-ups-on-supabase-with-cloudflare-turnstile-5cpk</link>
      <guid>https://dev.to/aravind-govindhasamy/stop-bot-sign-ups-on-supabase-with-cloudflare-turnstile-5cpk</guid>
      <description>&lt;p&gt;Bots find sign-up forms within days of launch. They create junk accounts and, worse, use your password-reset and magic-link forms to send email to people who never asked for it, which hurts your sending reputation. Supabase Auth can require a CAPTCHA on every sign-up, sign-in and reset, and Cloudflare Turnstile is free and usually invisible to real users.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The Turnstile widget runs in your page and produces a short-lived token.&lt;/li&gt;
&lt;li&gt;Your frontend passes that token to Supabase with the auth call.&lt;/li&gt;
&lt;li&gt;Supabase checks the token with Cloudflare using your secret key, and refuses the request if it's missing or invalid.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Your backend doesn't need any changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Create a Turnstile widget
&lt;/h2&gt;

&lt;p&gt;In the Cloudflare dashboard, open &lt;strong&gt;Turnstile → Add widget&lt;/strong&gt; :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Name:&lt;/strong&gt; your app's name&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hostnames:&lt;/strong&gt; your domain (&lt;code&gt;example.com&lt;/code&gt;) and &lt;code&gt;localhost&lt;/code&gt; for development&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Widget mode:&lt;/strong&gt;  &lt;strong&gt;Managed&lt;/strong&gt; is a good default; most visitors never see a challenge&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Copy the &lt;strong&gt;Site key&lt;/strong&gt; (public, goes in your frontend) and the &lt;strong&gt;Secret key&lt;/strong&gt; (goes in Supabase).&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Add the widget to your forms
&lt;/h2&gt;

&lt;p&gt;With React, the &lt;code&gt;@marsidev/react-turnstile&lt;/code&gt; package is the simplest route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;npm&lt;/span&gt; &lt;span class="nx"&gt;install&lt;/span&gt; &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;marsidev&lt;/span&gt;&lt;span class="sr"&gt;/react-turnstil&lt;/span&gt;&lt;span class="err"&gt;e
&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Turnstile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@marsidev/react-turnstile&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;captchaToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setCaptchaToken&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Turnstile&lt;/span&gt; &lt;span class="nx"&gt;siteKey&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;TURNSTILE_SITE_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;onSuccess&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;setCaptchaToken&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then pass the token to every auth call Supabase protects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signUp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;captchaToken&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signInWithPassword&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;captchaToken&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signInWithOtp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;captchaToken&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resetPasswordForEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;captchaToken&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A token works once. After a failed attempt, reset the widget to get a fresh one.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Deploy the frontend first
&lt;/h2&gt;

&lt;p&gt;This order matters. Once Supabase requires a CAPTCHA, every sign-in without a token is refused, including from users still running your previous build. So:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Deploy the frontend with the widget and &lt;code&gt;captchaToken&lt;/code&gt; in place.&lt;/li&gt;
&lt;li&gt;Check that sign-in still works (Supabase ignores the token until step 4).&lt;/li&gt;
&lt;li&gt;Then turn on enforcement.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  4. Turn on CAPTCHA protection in Supabase
&lt;/h2&gt;

&lt;p&gt;In the Supabase dashboard, open &lt;strong&gt;Authentication → Attack Protection&lt;/strong&gt; (some dashboard versions call it &lt;strong&gt;Bot and Abuse Protection&lt;/strong&gt; ). Turn on &lt;strong&gt;Enable CAPTCHA protection&lt;/strong&gt; , choose &lt;strong&gt;Cloudflare Turnstile&lt;/strong&gt; , paste the &lt;strong&gt;Secret key&lt;/strong&gt; and save.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Test
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Sign up in a normal browser: it should work, usually without a visible challenge.&lt;/li&gt;
&lt;li&gt;Call the auth API without a token (for example with &lt;code&gt;curl&lt;/code&gt;): Supabase should refuse it with a CAPTCHA error.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"captcha verification process failed" for everyone&lt;/td&gt;
&lt;td&gt;The secret in Supabase doesn't match the site key's widget, or the hostname isn't on the widget's list.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Works in production, fails on localhost&lt;/td&gt;
&lt;td&gt;Add &lt;code&gt;localhost&lt;/code&gt; to the widget's hostnames.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fails on the second try&lt;/td&gt;
&lt;td&gt;Tokens are single-use; reset the widget after each attempt.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Using it with FastAPI Supabase Starter
&lt;/h2&gt;

&lt;p&gt;The widget and the &lt;code&gt;captchaToken&lt;/code&gt; wiring are already in the sign-in page. Put the site key in &lt;code&gt;.env&lt;/code&gt;, rebuild and deploy the frontend, then add the secret key in Supabase:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TURNSTILE_SITE_KEY=0x4AAAAAAA...

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep that variable set in every future build, or sign-in breaks once Supabase requires the CAPTCHA. See &lt;a href="https://starter.budeglobal.in/help/sign-in-methods" rel="noopener noreferrer"&gt;Sign-in methods&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>cloudflare</category>
      <category>security</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
