<?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: qzbxw</title>
    <description>The latest articles on DEV Community by qzbxw (@qzbxw).</description>
    <link>https://dev.to/qzbxw</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3982110%2F3fed82a5-fff9-4200-b5a7-e2dfc752eacb.jpg</url>
      <title>DEV Community: qzbxw</title>
      <link>https://dev.to/qzbxw</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/qzbxw"/>
    <language>en</language>
    <item>
      <title>How to Build a Telegram Crypto Checkout in Go (And Avoid Going Insane with Manual Checks)</title>
      <dc:creator>qzbxw</dc:creator>
      <pubDate>Sat, 13 Jun 2026 04:23:03 +0000</pubDate>
      <link>https://dev.to/qzbxw/how-to-build-a-telegram-crypto-checkout-in-go-and-avoid-going-insane-with-manual-checks-58ca</link>
      <guid>https://dev.to/qzbxw/how-to-build-a-telegram-crypto-checkout-in-go-and-avoid-going-insane-with-manual-checks-58ca</guid>
      <description>&lt;p&gt;We've all built that one MVP. You know the flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your bot spits out a USDT address.&lt;/li&gt;
&lt;li&gt;You ask the customer to reply with a transaction hash or, worse, a screenshot.&lt;/li&gt;
&lt;li&gt;You manually open Tronscan or Solscan.&lt;/li&gt;
&lt;li&gt;You squint at the network, token, recipient, amount, and confirmations.&lt;/li&gt;
&lt;li&gt;You manually grant them access.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That’s fine for your first five sales. At fifty, it turns into a miserable support queue. You are no longer a developer; you are a human blockchain explorer.&lt;/p&gt;

&lt;p&gt;Let's automate this properly. We are going to build a non-custodial checkout service in Go that creates invoices, catches signed webhooks, and fulfills Telegram orders &lt;em&gt;exactly once&lt;/em&gt;. We’ll use &lt;a href="https://recv.money/en/dev?utm_source=tg_devto&amp;amp;utm_medium=cpc&amp;amp;utm_campaign=devtopost" rel="noopener noreferrer"&gt;Recv&lt;/a&gt; as the processor because it skips the custody nonsense—funds go straight to your wallet, and it just pings you when a transfer matches.&lt;/p&gt;

&lt;p&gt;Here is how to do it without shooting yourself in the foot.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Architecture: Don't Trust the Client
&lt;/h3&gt;

&lt;p&gt;The core principle here is simple: your bot does not decide a payment is complete just because a user clicked an "I Paid" button. You wait for the server-side webhook.&lt;/p&gt;

&lt;p&gt;The flow looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User hits &lt;code&gt;/buy&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Go service creates a local order, then hits the &lt;code&gt;POST /v1/invoices&lt;/code&gt; API.&lt;/li&gt;
&lt;li&gt;User gets a hosted checkout URL and sends the crypto.&lt;/li&gt;
&lt;li&gt;Recv watcher spots the on-chain transfer and fires a signed &lt;code&gt;invoice.paid&lt;/code&gt; webhook.&lt;/li&gt;
&lt;li&gt;Go service verifies the HMAC signature, marks the order paid, and queues fulfillment.&lt;/li&gt;
&lt;li&gt;The Bot API finally delivers the product.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Rule 1: Your Database Owns the Order
&lt;/h3&gt;

&lt;p&gt;Your app owns the commercial order. The payment provider owns the payment invoice. They are related, but they are not the same thing.&lt;/p&gt;

&lt;p&gt;Drop the basic &lt;code&gt;is_paid&lt;/code&gt; boolean. Crypto is chaotic. People underpay, overpay, use the wrong network, or pay three hours after the invoice expires. Use a state machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;created -&amp;gt; awaiting_payment -&amp;gt; paid -&amp;gt; fulfilled
                            \-&amp;gt; payment_review
                            \-&amp;gt; expired

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

&lt;/div&gt;



&lt;p&gt;Always create your local order &lt;em&gt;before&lt;/em&gt; you call the invoice API. If the network drops during the API call, you still have a local record to retry or cancel.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rule 2: Idempotency is Not Optional
&lt;/h3&gt;

&lt;p&gt;When you hit the API to generate the invoice, use an &lt;code&gt;Idempotency-Key&lt;/code&gt; tied to your internal order ID (e.g., &lt;code&gt;create-invoice:ORDER_123&lt;/code&gt;). If your request drops and you retry, you won't accidentally spin up two payable invoices for the same cart.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Creating the invoice&lt;/span&gt;
&lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;recvClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreateInvoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;recv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreateInvoiceRequest&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Title&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;            &lt;span class="s"&gt;"Premium Access"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;BaseAmountUSD&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="s"&gt;"29.00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;PayableNetwork&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;   &lt;span class="s"&gt;"TRON"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;PayableAsset&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;     &lt;span class="s"&gt;"USDT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ExpiresInMinutes&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;PaymentOptions&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;recv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PaymentOption&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Network&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"TRON"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Asset&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"USDT"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Network&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"SOLANA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Asset&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"USDC"&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;&lt;strong&gt;Why is the payable amount suddenly 29.004281 instead of 29?&lt;/strong&gt;&lt;br&gt;
Because TRC-20 and ERC-20 transfers don't have reliable memo fields. If three people send exactly 29 USDT to the same wallet at the same time, you have no idea who paid for what. Generating a tiny unique suffix (like &lt;code&gt;.004281&lt;/code&gt;) acts as the matching key. This is why you must &lt;em&gt;always&lt;/em&gt; display the &lt;code&gt;payable_amount&lt;/code&gt; returned by the API, not your base USD amount.&lt;/p&gt;
&lt;h3&gt;
  
  
  Rule 3: Verify the Damn Signature
&lt;/h3&gt;

&lt;p&gt;If you accept JSON blindly just because it hit your endpoint, you are asking to be exploited.&lt;/p&gt;

&lt;p&gt;The webhook comes with an &lt;code&gt;X-recv-Signature&lt;/code&gt; containing an HMAC-SHA256 digest of the timestamp and the &lt;strong&gt;raw request body&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Do not parse the JSON first. Parsing and re-encoding changes whitespace and field orders, which destroys the signature. Verify the raw bytes, then unmarshal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;Verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secret&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rawBody&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timestampHeader&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signatureHeader&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tolerance&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c"&gt;// Parse timestamp and check against tolerance (e.g., 5 mins) to prevent replay attacks&lt;/span&gt;
        &lt;span class="c"&gt;// ... (timestamp validation logic) ...&lt;/span&gt;

        &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"v1="&lt;/span&gt;
        &lt;span class="n"&gt;provided&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;hex&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DecodeString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimPrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signatureHeader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

        &lt;span class="n"&gt;mac&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimSpace&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="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mac&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timestampHeader&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mac&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"."&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mac&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;)&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;mac&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c"&gt;// Use constant-time comparison to prevent timing leaks&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;provided&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="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ErrBadSignature&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;h3&gt;
  
  
  Rule 4: At-Least-Once Delivery Will Break Your Bot
&lt;/h3&gt;

&lt;p&gt;Webhook delivery is "at least once", not "exactly once".&lt;/p&gt;

&lt;p&gt;Imagine this: you receive the webhook, update the DB, send the Telegram message with the download link, and then your Go process panics before returning a &lt;code&gt;200 OK&lt;/code&gt;. The provider assumes the webhook failed and retries. Your bot sends the product again.&lt;/p&gt;

&lt;p&gt;The fix is idempotency in your database. Use an inbox table and process the event in a single transaction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 1. Deduplicate the event&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;webhook_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTHING&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- If RowsAffected is 0, halt. We've seen this before.&lt;/span&gt;

&lt;span class="c1"&gt;-- 2. Update the order&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'paid'&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;invoice_public_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 3. Queue the fulfillment, DO NOT execute it inline&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;fulfillment_jobs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;job_type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'deliver_product'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTHING&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Notice we didn't call the Telegram API inside the transaction. DB transactions aren't atomic with network calls. You write the &lt;em&gt;intent&lt;/em&gt; to fulfill to a durable queue, and a separate worker picks it up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Handling the Awkward States
&lt;/h3&gt;

&lt;p&gt;The happy path looks great in a tutorial, but edge cases are where your bot actually lives.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Underpayment:&lt;/strong&gt; A user withdraws from an exchange, and the exchange eats a $1 fee. The invoice expected 29.004281, but 28.004281 arrived. Do not auto-fulfill. Trigger an &lt;code&gt;invoice.underpaid&lt;/code&gt; event, store the observed amount, and alert support.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Late Payment:&lt;/strong&gt; Blockchains don't care if your 30-minute timer expired. The transaction might still confirm. Decide in your code if you'll fulfill it anyway, flag it for manual review, or offer an alternative.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrong Network:&lt;/strong&gt; USDT on TRON isn't USDT on Ethereum. Put the required network in massive, bold text right next to the address in your bot's UI.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stop wiring &lt;code&gt;invoice.paid&lt;/code&gt; directly to a &lt;code&gt;sendMessage&lt;/code&gt; call. Build the queue, verify your HMACs, and you'll actually be able to leave your checkout running overnight without waking up to a destroyed database. Full API references and supported chains are in the &lt;a href="https://recv.money/en/docs?utm_source=tg_devto&amp;amp;utm_medium=cpc&amp;amp;utm_campaign=devtopost" rel="noopener noreferrer"&gt;Recv docs&lt;/a&gt;. Keep your private keys offline.&lt;/p&gt;

</description>
      <category>go</category>
      <category>telegram</category>
      <category>webhooks</category>
      <category>cryptocurrency</category>
    </item>
  </channel>
</rss>
