<?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: Buhgalter</title>
    <description>The latest articles on DEV Community by Buhgalter (@buhgalterapp).</description>
    <link>https://dev.to/buhgalterapp</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%2F4214802%2F4ee242e8-65c6-480a-88c0-b285c3a4c525.png</url>
      <title>DEV Community: Buhgalter</title>
      <link>https://dev.to/buhgalterapp</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/buhgalterapp"/>
    <language>en</language>
    <item>
      <title>One merchant account, four side projects: how I keep the payment data from turning into a mess</title>
      <dc:creator>Buhgalter</dc:creator>
      <pubDate>Thu, 29 Oct 2026 21:04:03 +0000</pubDate>
      <link>https://dev.to/buhgalterapp/one-merchant-account-four-side-projects-how-i-keep-the-payment-data-from-turning-into-a-mess-4ojc</link>
      <guid>https://dev.to/buhgalterapp/one-merchant-account-four-side-projects-how-i-keep-the-payment-data-from-turning-into-a-mess-4ojc</guid>
      <description>&lt;p&gt;Most indie developers I know do not run one product. They run a VPN bot, a paid Discord or Telegram community, a small template shop and maybe a script nobody remembers launching. Each of them takes money. Sooner or later someone asks a very boring question: how much did the template shop actually make in September, after refunds?&lt;/p&gt;

&lt;p&gt;If all four projects share one payment account and one webhook handler, that question can eat an evening. This post is about structuring things so it takes a minute instead. I will use the Buhgalter payment API as the concrete example, because it has a notion of storefronts built in, but the design ideas apply to most providers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Storefronts instead of string prefixes
&lt;/h2&gt;

&lt;p&gt;The tempting shortcut is to put the project name into the order reference: &lt;code&gt;vpn-10231&lt;/code&gt;, &lt;code&gt;shop-554&lt;/code&gt;. It works until someone renames a project, or a junior developer forgets the prefix, or you need to group by project in a report the provider generates for you.&lt;/p&gt;

&lt;p&gt;Buhgalter lets one merchant cabinet hold several storefronts, each with its own &lt;code&gt;store_id&lt;/code&gt;. Revenue is split by storefront in the reports, so the provider already knows which project a payment belongs to. Your own database should mirror that:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;storefronts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;          &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- store_id from the cabinet&lt;/span&gt;
  &lt;span class="n"&gt;title&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;            &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;-- provider payment id&lt;/span&gt;
  &lt;span class="n"&gt;store_id&lt;/span&gt;      &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;storefronts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;reference&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;-- your own order id&lt;/span&gt;
  &lt;span class="n"&gt;amount_rub&lt;/span&gt;    &lt;span class="nb"&gt;NUMERIC&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;        &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;    &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reference&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 unique constraint on &lt;code&gt;(store_id, reference)&lt;/code&gt; matters. Two projects can both have an order number 1001 without colliding, and a retried request inside one project still lands on the same row.&lt;/p&gt;

&lt;h2&gt;
  
  
  One webhook endpoint or several?
&lt;/h2&gt;

&lt;p&gt;I tried both. A single endpoint that did all the work with a &lt;code&gt;switch&lt;/code&gt; on the storefront felt neat for a week, then one project needed a slow side effect (generating a license file) and started delaying confirmations for the others.&lt;/p&gt;

&lt;p&gt;What works better for me: the receiving endpoint stays tiny and identical for every project, and each project gets its own worker behind a queue. The receiver does three things.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Checks the &lt;code&gt;X-Buhgalter-Signature&lt;/code&gt; header, an HMAC-SHA256 of the body, before parsing anything.&lt;/li&gt;
&lt;li&gt;Writes the event to the &lt;code&gt;payments&lt;/code&gt; table using the unique key above, so a duplicate delivery becomes a no-op.&lt;/li&gt;
&lt;li&gt;Returns 200 fast and drops a job into that project's queue, picked by &lt;code&gt;store_id&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The provider retries failed deliveries up to 8 times over roughly 13 hours and keeps a delivery log you can resend from. That is generous, but it is not a reason to be slow. A receiver that answers in 50 ms never needs the retries in the first place.&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;hmac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&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;signature&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="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="nb"&gt;bool&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;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;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;return&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;signature&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Currency per project, decided once
&lt;/h2&gt;

&lt;p&gt;Payments come in as rubles through SBP, an NSPK QR code or a card number transfer. The balance on the merchant side is kept in USDT by default or in rubles if you switch it, and the rate is locked when the payment is created.&lt;/p&gt;

&lt;p&gt;For multi project setups I store the locked rate on every payment row. It sounds redundant until the day you need to explain why the VPN project's September revenue in USDT does not match a naive conversion at the end of month rate. With the rate on each row, the explanation is a &lt;code&gt;SUM&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You can also pull the current USDT/RUB rate with &lt;code&gt;GET /api/fx&lt;/code&gt; for dashboards, but never for historical numbers. History lives in your table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refunds belong to the same storefront
&lt;/h2&gt;

&lt;p&gt;Refunds can be full or partial and the platform does not charge a fee on them. The mistake I made early on was logging refunds in a separate global table. Then the template shop looked more profitable than it was, because its refunds were counted against nobody in particular.&lt;/p&gt;

&lt;p&gt;Put &lt;code&gt;store_id&lt;/code&gt; on refunds too, and compute net revenue per storefront:&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;store_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount_rub&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&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="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;gross&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SUM&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="n"&gt;amount_rub&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&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;refunded&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;refunds&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payment_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'1 month'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;  &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;store_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Month end without the spreadsheet archaeology
&lt;/h2&gt;

&lt;p&gt;The provider side gives you reports with created, paid, conversion, turnover, fee, credited and refunds, with days counted on Moscow time, plus Excel exports and a CSV aimed at 1C accounting software. Watch out for the Moscow day boundary if your server runs in UTC: a payment at 01:30 Moscow time is still "yesterday" in a UTC query. I keep a generated column with the Moscow date so my numbers and the export line up row for row.&lt;/p&gt;

&lt;p&gt;Once a month I diff my per storefront totals against the export. When they match, I am done. When they do not, the culprit has always been one of three things: a webhook that never got processed, a refund filed under the wrong project, or the timezone boundary above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing without real money
&lt;/h2&gt;

&lt;p&gt;Every project gets its own test run before going live. Requests with the &lt;code&gt;X-Buhgalter-Test&lt;/code&gt; header create test payments, up to 200 a day, which is plenty for a full flow per storefront: create, pay, receive the webhook, refund, check the report line.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell myself two years ago
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Give every project its own storefront from day one, even the tiny ones.&lt;/li&gt;
&lt;li&gt;Make the database key &lt;code&gt;(store_id, reference)&lt;/code&gt;, not just &lt;code&gt;reference&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Verify the signature first, store the event second, do the slow work last.&lt;/li&gt;
&lt;li&gt;Save the locked rate on every payment.&lt;/li&gt;
&lt;li&gt;Reconcile monthly against the provider export, on Moscow dates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is clever. It just turns "how much did project X make" into a query instead of a weekend.&lt;/p&gt;

&lt;p&gt;If you want to see how storefronts, webhooks and exports look on the provider side, the docs are public at &lt;a href="https://buhgalter.app/docs" rel="noopener noreferrer"&gt;https://buhgalter.app/docs&lt;/a&gt; and the service itself is at &lt;a href="https://buhgalter.app" rel="noopener noreferrer"&gt;https://buhgalter.app&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>payments</category>
      <category>indiehackers</category>
      <category>api</category>
    </item>
    <item>
      <title>Running a crypto exchange office in Russia: how we automated the ruble-to-USDT first mile</title>
      <dc:creator>Buhgalter</dc:creator>
      <pubDate>Thu, 29 Oct 2026 17:57:48 +0000</pubDate>
      <link>https://dev.to/buhgalterapp/running-a-crypto-exchange-office-in-russia-how-we-automated-the-ruble-to-usdt-first-mile-53c</link>
      <guid>https://dev.to/buhgalterapp/running-a-crypto-exchange-office-in-russia-how-we-automated-the-ruble-to-usdt-first-mile-53c</guid>
      <description>&lt;p&gt;Most writing about crypto payments is about wallets and chains. Very little is about the boring part that actually eats an exchanger's margin: getting rubles from a customer and turning them into USDT without a human in the loop.&lt;/p&gt;

&lt;p&gt;I work at Buhgalter (&lt;a href="https://buhgalter.app" rel="noopener noreferrer"&gt;buhgalter.app&lt;/a&gt;), a payment processing service. Our largest group of clients are exchange offices, and here is what we learned from them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The manual loop
&lt;/h2&gt;

&lt;p&gt;A typical small exchanger used to work like this. The customer sends rubles to a card. An operator checks the bank app, matches the screenshot, recalculates the rate, sends USDT. Every step is a place for errors and disputes, and the rate keeps moving while the operator looks for the transfer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the automated version looks like
&lt;/h2&gt;

&lt;p&gt;The exchanger's site creates a payment request through our API the moment the customer clicks. The customer pays via SBP (Russia's instant payment system), a QR code or a card number transfer. The USDT/RUB rate is locked when the payment is created, and the balance is credited in USDT by default. Auto-withdrawal then sends funds to a USDT wallet on TRC-20.&lt;/p&gt;

&lt;p&gt;For exchangers that need to know who is paying, there is a payment method with identity confirmation: the payer shows a passport and a selfie with it, a staff member reviews it once per payment. This cuts the number of disputed transfers a lot. And the crypto that comes out is clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  Money side
&lt;/h2&gt;

&lt;p&gt;We charge only for successful payments. Expired or cancelled requests cost nothing. The bigger the ticket, the lower the rate, and for serious volumes we build individual and VIP terms. Refunds, full or partial, carry no platform fee.&lt;/p&gt;

&lt;h2&gt;
  
  
  The human side
&lt;/h2&gt;

&lt;p&gt;Every merchant gets a personal manager on Telegram. That sounds like a perk until you run a business where a stuck payment at 2 a.m. means a lost client. We try to meet people halfway, whether they are a large exchanger, a VPN service selling access through a bot, or a creator selling a course.&lt;/p&gt;

&lt;p&gt;If you run something similar and want to compare notes, I am happy to talk in the comments.&lt;/p&gt;

&lt;p&gt;Alena Kotenkova, Buhgalter team&lt;br&gt;
&lt;a href="https://buhgalter.app" rel="noopener noreferrer"&gt;https://buhgalter.app&lt;/a&gt;&lt;/p&gt;

</description>
      <category>crypto</category>
      <category>payments</category>
      <category>fintech</category>
      <category>automation</category>
    </item>
    <item>
      <title>Five questions small online businesses ask before switching payment providers, and our straight answers</title>
      <dc:creator>Buhgalter</dc:creator>
      <pubDate>Thu, 29 Oct 2026 14:44:32 +0000</pubDate>
      <link>https://dev.to/buhgalterapp/five-questions-small-online-businesses-ask-before-switching-payment-providers-and-our-straight-3p4p</link>
      <guid>https://dev.to/buhgalterapp/five-questions-small-online-businesses-ask-before-switching-payment-providers-and-our-straight-3p4p</guid>
      <description>&lt;p&gt;I work on Buhgalter (&lt;a href="https://buhgalter.app" rel="noopener noreferrer"&gt;buhgalter.app&lt;/a&gt;), a payment service for small online businesses that sell to Russian-speaking customers: exchange offices, VPN services, digital goods shops, Telegram bots, creators with paid channels. Our line is that the service is for everyone, and we mean the people who usually hear no from bigger providers.&lt;/p&gt;

&lt;p&gt;Before anyone connects, the same five questions come up. Here they are with plain answers, because the questions say more about this market than any pitch could.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Will my customers be able to pay at all?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;They pay through SBP, by scanning a QR NSPK code, or with a card number transfer. These are the methods people already use in their bank apps every day. Nobody on the customer side needs to know anything about crypto.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What happens to the money after that?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It lands on a balance kept in USDT by default, or in rubles if you prefer. The exchange rate is locked when the payment is created, so a price you set in the morning means the same thing in the evening. You can withdraw to a USDT TRC-20 wallet or to a card or account, and auto-withdrawal can do it for you by a rule you set.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Do I need a developer?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Not to start. You can create a payment in the dashboard and send the link. If you do have a developer, there is a payment link, a widget, or a fully custom screen via API, plus signed webhooks, idempotency keys and a test mode with up to 200 test payments a day.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What does it cost?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Commission is charged only on successful payments. Unpaid or expired ones are free. Larger tickets get a lower rate, and bigger projects get individual or VIP terms. We would rather talk it through than force one rate on a VPN with 300 RUB tickets and an exchanger with 300,000 RUB ones.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What if a customer wants their money back?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Full or partial refunds with no platform fee. Disputes come with a receipt and the full payment history.&lt;/p&gt;

&lt;p&gt;Behind all five there is usually a sixth question: will a real person answer me, and can the terms bend? For us the answer is yes on both. Every store gets a personal manager after signup, and individual terms are normal practice, not a favour.&lt;/p&gt;

&lt;p&gt;If you run a small online service and have a question I did not cover, drop it in the comments. Happy to answer, including the uncomfortable ones.&lt;/p&gt;

&lt;p&gt;Buhgalter team, buhgalter.app&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>showdev</category>
      <category>payments</category>
      <category>startup</category>
    </item>
    <item>
      <title>Selling digital goods from a Telegram bot: a test-first payment integration</title>
      <dc:creator>Buhgalter</dc:creator>
      <pubDate>Thu, 29 Oct 2026 10:21:13 +0000</pubDate>
      <link>https://dev.to/buhgalterapp/selling-digital-goods-from-a-telegram-bot-a-test-first-payment-integration-4ngb</link>
      <guid>https://dev.to/buhgalterapp/selling-digital-goods-from-a-telegram-bot-a-test-first-payment-integration-4ngb</guid>
      <description>&lt;p&gt;Most payment tutorials start with the happy path. Create a payment, get paid, ship the product. Then production happens: a webhook arrives twice, the bot restarts mid-delivery, a buyer pays an expired link. This post walks through how I would wire up payments for a bot that sells game keys or in-game currency, starting from tests instead of from the checkout button.&lt;/p&gt;

&lt;p&gt;The examples target the Buhgalter payments API. Field names are simplified; check the public docs for exact schemas.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the flow
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The buyer taps Buy in the bot.&lt;/li&gt;
&lt;li&gt;The bot calls POST /api/payments with the amount in rubles and its own order id as the reference.&lt;/li&gt;
&lt;li&gt;The API returns a payment link or a ready QR (SBP or NSPK QR).&lt;/li&gt;
&lt;li&gt;The buyer pays in their banking app.&lt;/li&gt;
&lt;li&gt;A signed webhook hits your server, you verify it and deliver the key.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Five steps, and every one of them can fail independently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start in test mode
&lt;/h2&gt;

&lt;p&gt;Send the X-Buhgalter-Test header and you get test payments, up to 200 per day. That is enough to run an integration suite on every push. My suite has four cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;paid once, key delivered once;&lt;/li&gt;
&lt;li&gt;webhook delivered twice, key still delivered once;&lt;/li&gt;
&lt;li&gt;link expired, nothing delivered, stock released;&lt;/li&gt;
&lt;li&gt;refund issued, key marked revoked.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If those four pass, the rest is mostly UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency on the way in
&lt;/h2&gt;

&lt;p&gt;Buyers double tap. Bots retry on timeouts. Use the order id for both the reference and the Idempotency-Key header:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_payment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&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;session&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="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;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/api/payments&lt;/span&gt;&lt;span class="sh"&gt;"&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;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reference&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&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;Idempotency-Key&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;order-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&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="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;65&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 same key returns the same payment, so the buyer never sees two QR codes for one order.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency on the way out
&lt;/h2&gt;

&lt;p&gt;Webhooks are retried up to 8 times over roughly 13 hours, and you can resend manually from the delivery log in the dashboard. So the handler must be safe to run twice:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_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="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lock&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;reference&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;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delivered&lt;/span&gt;&lt;span class="sh"&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reserve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sku&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mark_delivered&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;bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat_id&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;Your key: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lock the row, check the status, then deliver. Sending the message after commit means a crash never leaves a delivered key without a record.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the signature on the raw body
&lt;/h2&gt;

&lt;p&gt;Each webhook carries X-Buhgalter-Signature, an HMAC-SHA256 of the body. Compute it over the raw bytes before any JSON parsing, and compare with a constant-time function. Skipping this step lets anyone forge a paid event and drain your key stock.&lt;/p&gt;

&lt;h2&gt;
  
  
  Expiry and stock
&lt;/h2&gt;

&lt;p&gt;Payment links live 30 minutes by default, configurable from one minute to a day. For limited keys I reserve stock when the payment is created and release it on expiry. A shorter link lifetime keeps popular items from being locked by people who walked away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refunds
&lt;/h2&gt;

&lt;p&gt;Refunds can be full or partial and carry no platform fee. In a key shop the rule I use is simple: unactivated key, full refund; activated key, case by case. Whatever you choose, write it in the bot description before the first sale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting paid out
&lt;/h2&gt;

&lt;p&gt;The balance defaults to USDT, with the rate locked when the payment is created, or you can keep it in rubles. Withdrawals go to a bank account, a card, or a USDT TRC-20 wallet. If your supplier sells keys for crypto, auto payout to a wallet removes a manual step from restocking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap up
&lt;/h2&gt;

&lt;p&gt;Write the four tests first, then build the button. It is a boring order of work and it saves a lot of late night debugging. The docs, with samples in cURL, PHP, Python, Node.js and Java, are at &lt;a href="https://buhgalter.app" rel="noopener noreferrer"&gt;https://buhgalter.app&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Alena Kotenkova, Buhgalter team&lt;/p&gt;

</description>
      <category>telegram</category>
      <category>payments</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Accepting ruble payments in a Telegram bot: webhooks, idempotency and signature checks</title>
      <dc:creator>Buhgalter</dc:creator>
      <pubDate>Thu, 29 Oct 2026 08:58:21 +0000</pubDate>
      <link>https://dev.to/buhgalterapp/accepting-ruble-payments-in-a-telegram-bot-webhooks-idempotency-and-signature-checks-553</link>
      <guid>https://dev.to/buhgalterapp/accepting-ruble-payments-in-a-telegram-bot-webhooks-idempotency-and-signature-checks-553</guid>
      <description>&lt;p&gt;I am on the team at Buhgalter (buhgalter.app), a payment API for Russian instant payments (SBP, QR codes, card transfers) with payouts and USDT settlement. This post is a practical walkthrough for developers who want to wire payments into a bot or a small web app. Most of it applies to any payment API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Create a payment
&lt;/h2&gt;

&lt;p&gt;One call: &lt;code&gt;POST /api/payments&lt;/code&gt;. Amount is in RUB, from 1 to 100,000,000. You pass your own &lt;code&gt;reference&lt;/code&gt; (your order id) and optional return URLs.&lt;/p&gt;

&lt;p&gt;You choose between sync and async. Sync keeps the connection open until a payment detail or QR is ready, up to 60 seconds. Async returns immediately and delivers the result to your webhook. For a bot, sync is simpler. For anything with a job queue, go async.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency done right
&lt;/h2&gt;

&lt;p&gt;Send an &lt;code&gt;Idempotency-Key&lt;/code&gt; header. A retry with the same key returns the existing payment instead of a new one. We also dedupe on &lt;code&gt;reference&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Derive the key from your order id. If you generate a fresh UUID on every attempt, a timeout plus a retry still produces two invoices.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the webhook
&lt;/h2&gt;

&lt;p&gt;Every webhook carries &lt;code&gt;X-Buhgalter-Signature&lt;/code&gt;, an HMAC-SHA256 of the raw body. In Node.js:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;crypto&lt;/span&gt;&lt;span class="dl"&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;valid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&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;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&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;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;header&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;Use the raw bytes. Express with &lt;code&gt;express.json()&lt;/code&gt; will hand you a parsed object, and re-serializing it breaks the signature. Use &lt;code&gt;express.raw()&lt;/code&gt; on this route.&lt;/p&gt;

&lt;h2&gt;
  
  
  Expect duplicates and downtime
&lt;/h2&gt;

&lt;p&gt;Delivery is retried up to 8 times over about 13 hours. The dashboard shows a delivery log and lets you resend any event. Your handler must be idempotent: mark the order paid once, return 200 on repeats.&lt;/p&gt;

&lt;p&gt;Grant access only on the paid status. Not on creation, not on redirect back from the payment page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test mode
&lt;/h2&gt;

&lt;p&gt;Add the &lt;code&gt;X-Buhgalter-Test&lt;/code&gt; header to run in sandbox, up to 200 test payments a day. Run success, cancel and expiry. Payment links live 30 minutes by default, configurable from 1 minute to 24 hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Payouts and FX
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;POST /api/payouts&lt;/code&gt; sends money to a card, phone or account. &lt;code&gt;GET /api/fx&lt;/code&gt; returns the current USDT/RUB rate. The rate for each payment is fixed at creation, and your balance can be held in USDT and auto-withdrawn to a TRC-20 wallet.&lt;/p&gt;

&lt;p&gt;Docs with cURL, PHP, Python, Node.js and Java examples: &lt;a href="https://buhgalter.app/docs" rel="noopener noreferrer"&gt;https://buhgalter.app/docs&lt;/a&gt; . A first QR takes about ten minutes, the full setup with webhook verification about an hour.&lt;/p&gt;

&lt;p&gt;Questions welcome in the comments.&lt;/p&gt;

&lt;p&gt;Alena Kotenkova, Buhgalter team&lt;/p&gt;

</description>
      <category>payments</category>
      <category>webhooks</category>
      <category>telegram</category>
      <category>api</category>
    </item>
  </channel>
</rss>
