<?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: Coldtill</title>
    <description>The latest articles on DEV Community by Coldtill (@coldtill).</description>
    <link>https://dev.to/coldtill</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%2F4190126%2Fcc2fae49-657d-436b-bb18-d11c601b5c7e.png</url>
      <title>DEV Community: Coldtill</title>
      <link>https://dev.to/coldtill</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/coldtill"/>
    <language>en</language>
    <item>
      <title>Break your crypto checkout on testnet before a customer does</title>
      <dc:creator>Coldtill</dc:creator>
      <pubDate>Sun, 01 Nov 2026 10:18:48 +0000</pubDate>
      <link>https://dev.to/coldtill/break-your-crypto-checkout-on-testnet-before-a-customer-does-1mln</link>
      <guid>https://dev.to/coldtill/break-your-crypto-checkout-on-testnet-before-a-customer-does-1mln</guid>
      <description>&lt;p&gt;The first real crypto payment on a new shop is a bad time to find out your webhook handler only works when the customer pays the exact amount. Card payments mostly fail in tidy ways. Crypto payments fail in untidy ones: people send a bit too little, pay twice, pay after the link expired, or open a second checkout in another tab.&lt;/p&gt;

&lt;p&gt;None of that is exotic, and all of it can be rehearsed with test coins before you switch anything on for real. This post is the test plan I run, with a tiny webhook receiver you can copy. I'm using &lt;a href="https://coldtill.com" rel="noopener noreferrer"&gt;Coldtill&lt;/a&gt;, the self-hosted gateway we build, so the field names and statuses below are its own, but the list of cases applies to any crypto checkout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Set up a server you're allowed to break
&lt;/h2&gt;

&lt;p&gt;Coldtill installs in testnet mode unless you tell it otherwise, and a testnet install is a separate server from your mainnet one. That's deliberate: you can be careless with it.&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;apt &lt;span class="nb"&gt;install &lt;/span&gt;docker.io docker-compose-v2 curl
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://coldtill.com/install.sh | &lt;span class="nb"&gt;sudo &lt;/span&gt;bash &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nt"&gt;--edition&lt;/span&gt; free
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script fetches the Free release from the &lt;a href="https://github.com/Coldtill/self-hosted-crypto-payment-gateway" rel="noopener noreferrer"&gt;self-hosted crypto payment gateway repo on GitHub&lt;/a&gt;, checks its SHA-256 and starts everything in &lt;code&gt;/opt/coldtill&lt;/code&gt;. Finish &lt;strong&gt;Get started&lt;/strong&gt; in the dashboard: two-factor on your account, a cold wallet, and at least one deposit wallet.&lt;/p&gt;

&lt;p&gt;In testnet mode each network code maps to its test network:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Code&lt;/th&gt;
&lt;th&gt;Testnet&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ETH&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sepolia&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BASE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Base Sepolia&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;POLYGON&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Amoy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BSC&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;BSC testnet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TRON&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Nile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SOL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Devnet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BTC&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Signet&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I'd pick one network and stick with it for the whole run. Base Sepolia USDC is the easiest, because the amounts are in dollars and you don't have to think about price movements while you're reading logs. Sepolia ETH from a faucet works too. Get the test coins into an ordinary browser wallet that plays the customer.&lt;/p&gt;

&lt;p&gt;Then create an API key and a webhook endpoint under &lt;strong&gt;API keys&lt;/strong&gt; and &lt;strong&gt;Webhooks&lt;/strong&gt;. On a testnet install, webhook endpoints can be plain &lt;code&gt;http://localhost&lt;/code&gt; URLs, which saves you a tunnel. (Mainnet only accepts public &lt;code&gt;https&lt;/code&gt; endpoints, so don't get attached to this.)&lt;/p&gt;

&lt;h2&gt;
  
  
  A receiver that logs everything
&lt;/h2&gt;

&lt;p&gt;You don't need your real shop for the first pass. A thirty-line receiver that verifies the signature and prints what arrived tells you more, because nothing is hidden behind your order logic.&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="c1"&gt;# receiver.py  (python3 receiver.py)
&lt;/span&gt;&lt;span class="kn"&gt;import&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;os&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;from&lt;/span&gt; &lt;span class="n"&gt;http.server&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseHTTPRequestHandler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HTTPServer&lt;/span&gt;

&lt;span class="n"&gt;SECRET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;COLDTILL_WEBHOOK_SECRET&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="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&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&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;raw&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="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-Coldtill-Timestamp&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;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isdigit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;or&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;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="n"&gt;want&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sha256=&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;ts&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="sa"&gt;b&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="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;raw&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;want&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-Coldtill-Signature-V2&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="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Hook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseHTTPRequestHandler&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;do_POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&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;self&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;Content-Length&lt;/span&gt;&lt;span class="sh"&gt;"&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;if&lt;/span&gt; &lt;span class="ow"&gt;not&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;self&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;raw&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;REJECTED 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;flush&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="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_response&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end_headers&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;delivery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&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-Coldtill-Delivery&lt;/span&gt;&lt;span class="sh"&gt;"&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="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;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;dup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; (repeat delivery)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;delivery&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
        &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delivery&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;print&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;body&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;timestamp_ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&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;body&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;invoice_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&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;body&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;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;filled=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;body&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;filled_amount_in_usd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;conf=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;body&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;confirmation_current&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&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;body&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;confirmation_required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;dup&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;flush&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="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end_headers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nc"&gt;HTTPServer&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8099&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;Hook&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;serve_forever&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in there matter more than they look. The signature is computed over the timestamp plus the &lt;strong&gt;raw&lt;/strong&gt; body, so read the bytes before you parse any JSON. And decide what happened from the signed body's &lt;code&gt;status&lt;/code&gt;, not from the &lt;code&gt;X-Coldtill-Event&lt;/code&gt; header, which isn't signed.&lt;/p&gt;

&lt;p&gt;Run it somewhere the Coldtill server can reach, point the webhook at it (for example &lt;code&gt;http://localhost:8099/&lt;/code&gt;), and press &lt;strong&gt;Webhooks â†’ Send test&lt;/strong&gt;. You should see one line printed. Now change one character of the secret in your environment, restart, and send the test again. You want a &lt;code&gt;REJECTED&lt;/code&gt; line. If a receiver happily accepts a forged request in testing, it'll do the same in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cases
&lt;/h2&gt;

&lt;p&gt;I create each payment with curl so I control the amount and the customer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pay&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;  &lt;span class="c"&gt;# pay &amp;lt;customerID&amp;gt; &amp;lt;invoiceID&amp;gt; &amp;lt;usd&amp;gt; [expire]&lt;/span&gt;
  curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$COLDTILL_URL&lt;/span&gt;&lt;span class="s2"&gt;/api/v1/payment"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"API-Key: &lt;/span&gt;&lt;span class="nv"&gt;$COLDTILL_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;customerID&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;invoiceID&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;amountInUSD&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;$3&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;4&lt;/span&gt;:+,&lt;span class="p"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;"expire&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$4&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}}"&lt;/span&gt;&lt;span class="p"&gt;
  echo
&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set &lt;code&gt;COLDTILL_URL&lt;/code&gt; to your testnet server's site URL first. Then work down this list, one payment at a time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The happy path.&lt;/strong&gt; &lt;code&gt;pay c1 T-001 5&lt;/code&gt;, open the returned &lt;code&gt;url&lt;/code&gt;, pay exactly 5 USDC. Watch progress lines with climbing confirmation counts, then one final &lt;code&gt;FILLED&lt;/code&gt;. This is the only case most people test.&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faamz2ryn83e3zls45kw5.png" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faamz2ryn83e3zls45kw5.png" alt="Coldtill checkout: choose a network and coin" width="732" height="851"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The customer's first step: pick a network and coin. Note the expiry countdown under the button; case 4 below is about what happens when it reaches zero.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Too little.&lt;/strong&gt; &lt;code&gt;pay c2 T-002 5&lt;/code&gt;, send 3. You should get &lt;code&gt;PARTIALLY_FILLED&lt;/code&gt;, and the checkout should ask for the remaining amount. Check that your shop does &lt;em&gt;not&lt;/em&gt; ship. Then send the remaining 2 and confirm it moves to &lt;code&gt;FILLED&lt;/code&gt;. Coldtill counts anything within 1% of the amount owed as filled, so a few cents of rounding from a wallet won't strand an order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Too much.&lt;/strong&gt; &lt;code&gt;pay c3 T-003 5&lt;/code&gt;, send 6. Expect &lt;code&gt;OVER_FILLED&lt;/code&gt;. Treat it as paid. Whether you refund the extra is a business decision, but your code needs to know this status exists, or the order sits there looking unpaid.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Nobody pays.&lt;/strong&gt; Create one that expires soon. The &lt;code&gt;expire&lt;/code&gt; field takes an RFC 3339 time at least a minute ahead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pay c4 T-004 5 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'+3 minutes'&lt;/span&gt; +%Y-%m-%dT%H:%M:%SZ&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait, and you should see &lt;code&gt;CANCELLED&lt;/code&gt;. Your shop should cancel the order or offer a new link. (The default expiry is 24 hours, which is why nobody ever tests this.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Pays after the deadline.&lt;/strong&gt; Now send funds to the expired payment from case 4. The payment stays closed. You'll get a progress event showing a new &lt;code&gt;filled_amount&lt;/code&gt;, and no &lt;code&gt;FILLED&lt;/code&gt;. This one needs a human decision, so make sure it lands somewhere a human will see it rather than in a log nobody reads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Changes their mind.&lt;/strong&gt; &lt;code&gt;pay c5 T-005 5&lt;/code&gt;, then immediately &lt;code&gt;pay c5 T-006 7&lt;/code&gt; for the same customer. A customer has at most one open payment, so the first becomes &lt;code&gt;REPLACED&lt;/code&gt;. Ignore it in your shop; the newer one carries on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Changes their mind mid-payment.&lt;/strong&gt; Same as above, but send coins to the first one and create the second while the deposit is still confirming. The API answers &lt;code&gt;409&lt;/code&gt; rather than replacing a payment with money on its way. Your checkout should show something like "try again in a few minutes" instead of a stack trace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. The same event twice.&lt;/strong&gt; Under &lt;strong&gt;Webhooks â†’ Deliveries&lt;/strong&gt; you can resend any delivery by hand. Resend the &lt;code&gt;FILLED&lt;/code&gt; from case 1. The receiver will flag it as a repeat; your real handler has to make it a no-op. Final events are retried for up to two days if your endpoint doesn't answer, so idempotency isn't optional.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9. Your endpoint is down.&lt;/strong&gt; Stop the receiver, pay a fresh invoice, and start it again a few minutes later. The final event isn't lost: after two quick retries the next attempt comes 30 minutes later, then at growing intervals for two days. (If you're impatient, resend it from &lt;strong&gt;Deliveries&lt;/strong&gt;.) Progress events only retry twice, so don't build anything that depends on seeing every one of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;10. Events out of order.&lt;/strong&gt; Delivery order isn't guaranteed. Sort by &lt;code&gt;timestamp_ms&lt;/code&gt; (two events can share a second) and never let an older progress event overwrite a final status.&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdp6kxdpfwjmb8o90rsxb.png" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdp6kxdpfwjmb8o90rsxb.png" alt="Coldtill dashboard: Payments list" width="800" height="456"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The Payments list after a test run. Partial, over-filled and cancelled payments are all there to compare against what your shop recorded.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Then plug in the real shop
&lt;/h2&gt;

&lt;p&gt;Once the receiver behaves, swap it for your shop's real endpoint and run the same ten cases again, this time checking order statuses instead of printed lines. Add one more check per case: the &lt;code&gt;reference_id&lt;/code&gt; in the webhook must match the one you stored for that &lt;code&gt;invoice_id&lt;/code&gt;. Without that, a late event from an old payment can mark a newer order paid.&lt;/p&gt;

&lt;h2&gt;
  
  
  What testnet can't tell you
&lt;/h2&gt;

&lt;p&gt;A few things only show up on mainnet, so plan for them rather than assume they're covered:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auto-convert is hidden in testnet mode&lt;/strong&gt;, because the swap providers only run on mainnet. If you'll take coins through it, test that flow with a small real payment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Public RPC defaults&lt;/strong&gt; are fine for testing and rate limited for anything else. Add your own or paid nodes before go-live, ideally more than one per network.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your real network.&lt;/strong&gt; DNS, TLS, a firewall that only lets the office in, or a webhook URL with a stray &lt;code&gt;www&lt;/code&gt; will all pass on localhost.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the last step is always the same: install the mainnet server separately with &lt;code&gt;--mode mainnet --site-url https://pay.yourshop.com&lt;/code&gt;, point the shop at it and pay yourself $1. If that dollar shows up as &lt;code&gt;FILLED&lt;/code&gt; in your shop and in your cold wallet, you've done more testing than most crypto checkouts ever get.&lt;/p&gt;

&lt;p&gt;The webhook and API details used above are in the &lt;a href="https://coldtill.com/docs/" rel="noopener noreferrer"&gt;Coldtill docs&lt;/a&gt;, if you want to check a field before relying on it.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>webdev</category>
      <category>python</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Adding a crypto checkout to WooCommerce without signing up for a processor</title>
      <dc:creator>Coldtill</dc:creator>
      <pubDate>Sat, 31 Oct 2026 10:07:23 +0000</pubDate>
      <link>https://dev.to/coldtill/adding-a-crypto-checkout-to-woocommerce-without-signing-up-for-a-processor-98b</link>
      <guid>https://dev.to/coldtill/adding-a-crypto-checkout-to-woocommerce-without-signing-up-for-a-processor-98b</guid>
      <description>&lt;p&gt;Most guides to taking crypto in WooCommerce start the same way: create an account with a payment processor, upload your company documents, wait for a review, then install their plugin and paste in an API key. The plugin is the easy part. The account is what slows you down, and it's also the thing that can be closed later.&lt;/p&gt;

&lt;p&gt;This walkthrough takes a different route. The plugin talks to a payment server you run yourself, so there's no account to apply for. I'll use &lt;a href="https://coldtill.com" rel="noopener noreferrer"&gt;Coldtill&lt;/a&gt;, the gateway we build, and its WooCommerce plugin. It's a short job, but there are a few details (store currency, webhook URLs, partial payments) that are worth knowing before a real customer finds them for you.&lt;/p&gt;

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

&lt;p&gt;On the WordPress side:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;WordPress 6.4 or later, WooCommerce 8.0 or later, PHP 8.1 or later&lt;/li&gt;
&lt;li&gt;Store currency set to &lt;strong&gt;USD&lt;/strong&gt;. Coldtill prices payments in US dollars, so the gateway simply doesn't show up at checkout if your store uses another currency. This is the most common "I installed it and nothing happened" moment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the payment side, a Coldtill server: a Linux box with Docker. 2 vCPU and 4 GB of memory is plenty, on amd64 or arm64. Put it on its own small VPS, not on the machine that runs WordPress. If a plugin vulnerability ever gets someone into your shop, it shouldn't hand them the payment server too.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Install the payment server
&lt;/h2&gt;

&lt;p&gt;The Free edition installs with one command and doesn't need an account:&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="c"&gt;# Ubuntu or Debian&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;docker.io docker-compose-v2 curl
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://coldtill.com/install.sh | &lt;span class="nb"&gt;sudo &lt;/span&gt;bash &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nt"&gt;--edition&lt;/span&gt; free
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script pulls the latest release from the &lt;a href="https://github.com/Coldtill/self-hosted-crypto-payment-gateway" rel="noopener noreferrer"&gt;Coldtill/self-hosted-crypto-payment-gateway&lt;/a&gt; repository, checks its SHA-256 against the published checksums and starts the stack in &lt;code&gt;/opt/coldtill&lt;/code&gt;. It installs on &lt;strong&gt;testnet&lt;/strong&gt; by default, which is exactly what you want while wiring up the shop. When you're ready for real money, you install with &lt;code&gt;--mode mainnet --site-url https://pay.yourshop.com&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Once it's up, the dashboard's &lt;strong&gt;Get started&lt;/strong&gt; page walks you through adding a cold wallet and at least one deposit wallet. One habit worth forming here: when the dashboard shows you the first deposit address it derived, check it against your own wallet before you share a single payment link.&lt;/p&gt;

&lt;p&gt;A quick sanity check from your laptop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://pay.yourshop.com/healthz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that answers, the shop will be able to reach it too.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Install the plugin
&lt;/h2&gt;

&lt;p&gt;The plugin is MIT-licensed and lives in its own repo. Grab &lt;code&gt;coldtill-woocommerce.zip&lt;/code&gt; from the &lt;a href="https://github.com/Coldtill/woocommerce-crypto-payment-gateway/releases/latest" rel="noopener noreferrer"&gt;latest WooCommerce plugin release&lt;/a&gt; and upload it under &lt;strong&gt;Plugins → Add New → Upload Plugin&lt;/strong&gt;, then activate it.&lt;/p&gt;

&lt;p&gt;If you manage WordPress from the command line, WP-CLI can install straight from the release URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp plugin &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://github.com/Coldtill/woocommerce-crypto-payment-gateway/releases/latest/download/coldtill-woocommerce.zip &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--activate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It supports both the classic checkout and the block-based checkout, and it's compatible with WooCommerce's high-performance order storage (HPOS), so you don't need to switch anything back to make it work.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Create an API key, and make it a payments-only one
&lt;/h2&gt;

&lt;p&gt;In the Coldtill dashboard, open &lt;strong&gt;API keys&lt;/strong&gt; and create a new key for the project. Coldtill keys come in two scopes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Payments only:&lt;/strong&gt; create payments and read their status.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full access:&lt;/strong&gt; everything above, plus sending payouts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A shop plugin only needs the first. Pick payments-only here. The scope can't be changed after the key is created, so if you get it wrong, make a new key and revoke the old one. The key is shown once; paste it straight into WordPress.&lt;/p&gt;

&lt;p&gt;In &lt;strong&gt;WooCommerce → Settings → Payments → Coldtill&lt;/strong&gt;, enter your Coldtill URL (for example &lt;code&gt;https://pay.yourshop.com&lt;/code&gt;) and the key.&lt;/p&gt;

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

&lt;p&gt;The plugin settings show a webhook URL under "Webhook secret". It ends in &lt;code&gt;/wc-api/coldtill/&lt;/code&gt;, something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://shop.example.com/wc-api/coldtill/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Back in Coldtill, go to &lt;strong&gt;Webhooks&lt;/strong&gt;, add an endpoint with that URL, copy the signing secret it gives you (also shown once) and paste it into the plugin settings.&lt;/p&gt;

&lt;p&gt;The plugin verifies every webhook with HMAC-SHA256 over a timestamp and the body, and rejects anything with a bad signature or a timestamp outside the replay window. You don't have to write that code, but it's worth knowing it's there, because it explains one failure mode: if your WordPress server's clock is badly off, valid webhooks will be refused.&lt;/p&gt;

&lt;p&gt;Two other things that bite here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Redirects aren't followed.&lt;/strong&gt; If &lt;code&gt;http://&lt;/code&gt; redirects to &lt;code&gt;https://&lt;/code&gt;, or &lt;code&gt;shop.example.com&lt;/code&gt; redirects to &lt;code&gt;www.shop.example.com&lt;/code&gt;, the webhook won't arrive. Use the exact final URL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On mainnet, the URL has to be public HTTPS.&lt;/strong&gt; Coldtill refuses private and reserved addresses. On a testnet install you can point it at &lt;code&gt;http://localhost&lt;/code&gt; while you develop.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. What happens at checkout
&lt;/h2&gt;

&lt;p&gt;When a customer chooses crypto, the plugin creates a payment on your server. It sends the order total in USD, the order number, the billing email, an internal customer id (the WordPress user id, or the order id for guests) and the order-received page address for the checkout's return button. Nothing goes to any other service.&lt;/p&gt;

&lt;p&gt;The customer lands on your own checkout page, picks a network and coin, and gets a QR code and an exact amount.&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl9al6canik30em090c73.png" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl9al6canik30em090c73.png" alt="The checkout page where the customer picks a network and currency" width="732" height="851"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The customer picks a network and currency on your own checkout page. The invoice number comes from the WooCommerce order.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Because the customer id is stable, a returning logged-in customer gets the same deposit addresses as before. On Ethereum, Base, Polygon, BNB Chain, Tron and Solana those addresses are deposit contracts that can only send to your cold wallet; Bitcoin goes to addresses from your own wallet's public key.&lt;/p&gt;
&lt;h2&gt;
  
  
  6. How order statuses map
&lt;/h2&gt;

&lt;p&gt;This is the part I'd read twice. Coldtill payments have a handful of states, and the plugin maps them onto WooCommerce like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Coldtill payment&lt;/th&gt;
&lt;th&gt;WooCommerce order&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Filled or overpaid&lt;/td&gt;
&lt;td&gt;Processing (payment complete)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Partially paid&lt;/td&gt;
&lt;td&gt;On hold&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cancelled or replaced&lt;/td&gt;
&lt;td&gt;Cancelled&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few notes from the docs that matter in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Filled&lt;/strong&gt; means the confirmed amount is within 1% of what's owed. &lt;strong&gt;Overpaid&lt;/strong&gt; means more than 1% above it. Both mean "ship it". If someone overpays, the plugin notes it on the order; you can refund the difference with a payout if you want to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partially paid&lt;/strong&gt; usually means the customer sent too little, often because their exchange took a withdrawal fee off the top. The checkout page asks them for the rest, and the order sits On hold until they send it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cancelled&lt;/strong&gt; happens when a payment expires unpaid. The default expiry is 24 hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replaced&lt;/strong&gt; means the same customer started a new payment, which supersedes the old one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's also a safety check: the order only changes when the payment's reference and amount match the order. If the amount Coldtill reports doesn't match the order total, the plugin adds a note and leaves the order alone rather than guessing. (Version 1.0.2 fixed a case where a payment exactly one cent off the total was left pending; it's now marked paid as intended.)&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fex1hlabvkjldgf1c1sky.jpg" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fex1hlabvkjldgf1c1sky.jpg" alt="The Payments page in the Coldtill dashboard" width="800" height="456"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The Payments page in the Coldtill dashboard. Each row lines up with a WooCommerce order through its invoice number, so a "partially filled" here is an "On hold" in WooCommerce.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  7. When a webhook goes missing
&lt;/h2&gt;

&lt;p&gt;Webhooks fail for boring reasons: a deploy, a firewall rule, a security plugin that dislikes POST requests. Coldtill retries final events on a long schedule (up to two days), and you can resend any delivery by hand under &lt;strong&gt;Webhooks → Deliveries&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The plugin also has a fallback: when the customer comes back to the order-received page, it asks the server for that payment's status, so the order still updates even if the webhook never arrived.&lt;/p&gt;

&lt;p&gt;If you're debugging an order that looks stuck, you can ask the server directly with the payment's reference id. It's the last part of the checkout link (&lt;code&gt;/pay/&amp;lt;reference_id&amp;gt;&lt;/code&gt;), which the dashboard's &lt;strong&gt;Payments&lt;/strong&gt; page links to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"API-Key: &lt;/span&gt;&lt;span class="nv"&gt;$COLDTILL_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://pay.yourshop.com/api/v1/payment/reference/fd09c7e5-d661-4355-9b04-7b3749b41e2e
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response includes the status and the deposits with their confirmation counts, which usually tells you straight away whether the problem is the chain (still confirming) or the shop (webhook not getting through).&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Test before you go live
&lt;/h2&gt;

&lt;p&gt;My usual order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;On the testnet install, use &lt;strong&gt;Webhooks → Send test&lt;/strong&gt; to post a signed test event to the shop and confirm it's accepted.&lt;/li&gt;
&lt;li&gt;Place a real order on the store and pay it with Base Sepolia USDC or Sepolia ETH from a faucet. Watch the order go from Pending to Processing.&lt;/li&gt;
&lt;li&gt;Pay too little on a second order and check it lands On hold.&lt;/li&gt;
&lt;li&gt;Let a third one expire, or create a payment with a short expiry through the API, and check it ends up Cancelled.&lt;/li&gt;
&lt;li&gt;Reinstall on mainnet, point the plugin at it, and pay yourself $1 before you announce anything.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last step catches more than you'd think: DNS, certificates, a firewall that only lets your office in, a webhook URL with a stray &lt;code&gt;www&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The plugin itself is small. Most of the work is in the server it talks to and in understanding the order states, and once those are clear there isn't much to maintain. If you'd rather read the code before installing anything on a production store, the plugin is in the &lt;a href="https://github.com/Coldtill/woocommerce-crypto-payment-gateway" rel="noopener noreferrer"&gt;woocommerce-crypto-payment-gateway repo&lt;/a&gt;, and the full setup docs are at &lt;a href="https://coldtill.com/docs/" rel="noopener noreferrer"&gt;coldtill.com/docs&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>woocommerce</category>
      <category>wordpress</category>
      <category>php</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Never mark a crypto order paid twice: confirmations, reorgs and out-of-order webhooks</title>
      <dc:creator>Coldtill</dc:creator>
      <pubDate>Thu, 29 Oct 2026 10:03:45 +0000</pubDate>
      <link>https://dev.to/coldtill/never-mark-a-crypto-order-paid-twice-confirmations-reorgs-and-out-of-order-webhooks-3ckf</link>
      <guid>https://dev.to/coldtill/never-mark-a-crypto-order-paid-twice-confirmations-reorgs-and-out-of-order-webhooks-3ckf</guid>
      <description>&lt;p&gt;A classic crypto checkout bug goes like this. A webhook comes in, the shop marks the order paid, a retry of the same webhook arrives four seconds later, and a second fulfilment job goes into the queue. It's easy to write, easy to miss in testing, and it's a good reason to think properly about what "paid" means when the money moves on a blockchain.&lt;/p&gt;

&lt;p&gt;Card payments trained most of us to treat a payment as a single yes/no event. On-chain payments are messier. A transfer is seen, then it gathers confirmations, and once in a while the block it landed in disappears. Customers send too little, or too much, or pay again an hour after the invoice expired. Your webhook receiver has to make sense of all of that, often out of order.&lt;/p&gt;

&lt;p&gt;This post is about the order-system side of that problem. I'll use the event model from &lt;a href="https://coldtill.com" rel="noopener noreferrer"&gt;Coldtill&lt;/a&gt;, the self-hosted gateway we build, because it's the one I know in detail, but the ideas carry over to most gateways.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens between "sent" and "paid"
&lt;/h2&gt;

&lt;p&gt;From the gateway's side, a single payment goes through roughly these stages:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Seen.&lt;/strong&gt; A transfer to the customer's deposit address shows up in a block (or, for Bitcoin, in the address history from an Esplora node).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confirming.&lt;/strong&gt; Each new block adds a confirmation. Every network has its own required count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confirmed.&lt;/strong&gt; Once there are enough, the deposit counts toward what the customer owes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reorged, occasionally.&lt;/strong&gt; If the block holding the transfer drops off the canonical chain, the deposit isn't confirmed any more.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In Coldtill's watcher, a deposit whose block disappears in a reorg goes back to &lt;code&gt;pending&lt;/code&gt;, and one whose transaction vanishes entirely is marked &lt;code&gt;orphaned&lt;/code&gt; and reversed. Block hashes are re-checked as confirmations are counted, rather than just counting block heights.&lt;/p&gt;

&lt;p&gt;The important thing for your shop: &lt;strong&gt;you should never have to reason about any of that yourself.&lt;/strong&gt; If your order code is counting confirmations, the gateway is leaking a concern it should own. What you want is a payment-level state that only reaches "paid" after the chain-level work is done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Payment states, not transfer events
&lt;/h2&gt;

&lt;p&gt;Coldtill reduces all of the above to a small set of payment states:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Final&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OPEN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Nothing confirmed yet&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PARTIALLY_FILLED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Confirmed amount below 99% of what's owed&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FILLED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Confirmed amount within 1% of what's owed&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OVER_FILLED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;More than 1% above what's owed&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CANCELLED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Expired before being filled (24 hours by default)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;REPLACED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Superseded by a newer payment for the same customer&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ship on &lt;code&gt;FILLED&lt;/code&gt; or &lt;code&gt;OVER_FILLED&lt;/code&gt;. Everything else is either "keep waiting" or "this order isn't going to be paid through this link".&lt;/p&gt;

&lt;p&gt;That 1% tolerance is worth a second look. Customers paying in a volatile coin, or paying from an exchange that shaves a little off, will rarely hit the exact figure. Deciding up front how close is close enough saves a lot of support emails.&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0njzz2tf4tbdnugtuvud.png" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0njzz2tf4tbdnugtuvud.png" alt="The payments list in the Coldtill dashboard, with filled, partially filled, pending and expired payments side by side" width="800" height="456"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The payments list in the Coldtill dashboard, with filled, partially filled, pending and expired payments side by side&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The four things that break naive receivers
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Retries
&lt;/h3&gt;

&lt;p&gt;Webhooks are delivered at least once, not exactly once. Coldtill retries progress events after 2 and 4 seconds, and final events on a longer schedule (2 s, 4 s, 30 min, 1 h, 2 h, 4 h, 8 h, 24 h, 48 h) before marking them failed. A retry reuses the same &lt;code&gt;X-Coldtill-Delivery&lt;/code&gt; ID, so that's your cheapest dedupe key.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Ordering
&lt;/h3&gt;

&lt;p&gt;Delivery order isn't guaranteed. A progress event can arrive after the final one if the first attempt of the progress event failed and was retried. The payload carries &lt;code&gt;timestamp_ms&lt;/code&gt;, and the docs say to order by that rather than &lt;code&gt;timestamp&lt;/code&gt;, because two events can share a second.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Late money
&lt;/h3&gt;

&lt;p&gt;Once a payment is closed, it stays closed. A deposit that lands after the payment is &lt;code&gt;CANCELLED&lt;/code&gt; still counts toward the filled amount, but doesn't reopen it; you get a progress event with the new &lt;code&gt;filled_amount&lt;/code&gt;. If you ignore everything after the final event, you'll never notice customers who paid late, and those are exactly the ones who email you angry.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Trusting the wrong field
&lt;/h3&gt;

&lt;p&gt;The event name header and the delivery ID header are not signed. Only the body is. So the decision "is this order paid?" has to come from the signed body's &lt;code&gt;status&lt;/code&gt; and &lt;code&gt;reference_id&lt;/code&gt;, never from &lt;code&gt;X-Coldtill-Event&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A receiver that survives all four
&lt;/h2&gt;

&lt;p&gt;Here's the shape I'd use. It's TypeScript with Express and Postgres, and it uses the SDK's verifier as shown in the webhook docs. The SQL is the part that matters; adapt it to whatever you run.&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;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;'&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;verifyWebhookRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isPaid&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="s1"&gt;@coldtill/sdk&lt;/span&gt;&lt;span class="dl"&gt;'&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;pool&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="s1"&gt;./db&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nx"&gt;app&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/coldtill/webhook&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&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;let&lt;/span&gt; &lt;span class="nx"&gt;ev&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;ev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;verifyWebhookRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&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="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COLDTILL_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="o"&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;catch&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&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="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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;payment.&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&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;delivery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-coldtill-delivery&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&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;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BEGIN&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;// Dedupe retries. The delivery ID isn't signed, so it's only used to skip work,&lt;/span&gt;
    &lt;span class="c1"&gt;// never to decide anything.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INSERT INTO webhook_seen (delivery_id) VALUES ($1) ON CONFLICT DO NOTHING&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="nx"&gt;delivery&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;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rowCount&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;COMMIT&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Keep the newest view of the payment. Older events that arrive late can't&lt;/span&gt;
    &lt;span class="c1"&gt;// overwrite newer state.&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`INSERT INTO crypto_payments (reference_id, order_id, status, filled_usd, event_ms)
       VALUES ($1, $2, $3, $4, $5)
       ON CONFLICT (reference_id) DO UPDATE
         SET status = EXCLUDED.status, filled_usd = EXCLUDED.filled_usd, event_ms = EXCLUDED.event_ms
         WHERE crypto_payments.event_ms &amp;lt; EXCLUDED.event_ms`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reference_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invoice_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;filled_amount_in_usd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timestamp_ms&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;// Fulfil once. The unique order_id on fulfilments is what actually&lt;/span&gt;
    &lt;span class="c1"&gt;// stops a second shipment, whatever happens above.&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isPaid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s2"&gt;`INSERT INTO fulfilments (order_id, reference_id) VALUES ($1, $2)
         ON CONFLICT (order_id) DO NOTHING`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invoice_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reference_id&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;else&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CANCELLED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;filled_amount_in_usd&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;0&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s2"&gt;`INSERT INTO payment_followups (reference_id, reason) VALUES ($1, 'late or short payment')
         ON CONFLICT DO NOTHING`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reference_id&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;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;COMMIT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ROLLBACK&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// let the gateway retry&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;release&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;A few choices in there are deliberate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Three layers of idempotency.&lt;/strong&gt; The delivery table skips cheap repeats. The &lt;code&gt;event_ms&lt;/code&gt; guard stops stale events overwriting fresh ones. The unique constraint on &lt;code&gt;fulfilments.order_id&lt;/code&gt; is the real safety net. If the first two have a bug, the third still holds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything in one transaction.&lt;/strong&gt; If the process dies halfway, nothing is half-recorded, and the &lt;code&gt;500&lt;/code&gt; makes the gateway try again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Late money goes to a human.&lt;/strong&gt; I don't auto-ship on a cancelled payment with money on it, because the price, the stock or the customer's intent may have changed. A follow-up queue is cheaper than guessing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Respond fast.&lt;/strong&gt; Coldtill wants a &lt;code&gt;2xx&lt;/code&gt; within 60 seconds. If fulfilment is slow, insert a job and let a worker do it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Replacing a payment safely
&lt;/h2&gt;

&lt;p&gt;One more case that surprises people. In Coldtill, each customer has at most one open payment, and creating a new one replaces the previous (&lt;code&gt;REPLACED&lt;/code&gt;). But if the old payment has a deposit still confirming, &lt;code&gt;POST /api/v1/payment&lt;/code&gt; returns &lt;code&gt;409&lt;/code&gt; instead, so that money isn't stranded on a dead invoice.&lt;/p&gt;

&lt;p&gt;In practice this shows up when a customer abandons checkout, changes their basket and comes back while the first transfer is still confirming. Treat the &lt;code&gt;409&lt;/code&gt; as "wait and retry", not as an error page. Something like "Your previous payment is still confirming, we'll update this page in a minute" is far better than a stack trace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't fulfil from the return page
&lt;/h2&gt;

&lt;p&gt;The hosted checkout has a "Return to store" button, and it's tempting to mark the order paid when the customer lands back on your thank-you page. Don't. Anyone can open that URL. The docs are blunt about it: opening the return URL tells the gateway nothing. Fulfil from the verified webhook, or from &lt;code&gt;GET /api/v1/payment/reference/{reference_id}&lt;/code&gt; if you need to poll as a fallback.&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkc8ouvrta68sfuwsea3a.png" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkc8ouvrta68sfuwsea3a.png" alt="The customer's view while a payment confirms: exact amount, QR code and confirmation progress" width="732" height="1185"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The customer's view while a payment confirms: exact amount, QR code and confirmation progress&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick test plan
&lt;/h2&gt;

&lt;p&gt;Before going live I run through this list on testnet:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pay the exact amount and check exactly one fulfilment row appears.&lt;/li&gt;
&lt;li&gt;Use &lt;strong&gt;Webhooks â†’ Deliveries&lt;/strong&gt; to resend the final event by hand and check nothing changes.&lt;/li&gt;
&lt;li&gt;Pay half, then the rest, and watch the order move from partially filled to filled.&lt;/li&gt;
&lt;li&gt;Let a payment expire, then pay it, and check it lands in the follow-up queue rather than shipping.&lt;/li&gt;
&lt;li&gt;Stop your receiver for a few minutes during a payment and confirm the retries catch up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is glamorous, but it's the difference between a crypto checkout you trust and one you check by hand every morning. If you want to see how the states look in a real dashboard, the live demo is linked from &lt;a href="https://coldtill.com" rel="noopener noreferrer"&gt;coldtill.com&lt;/a&gt;, and the &lt;a href="https://coldtill.com/docs/webhooks/" rel="noopener noreferrer"&gt;webhook docs&lt;/a&gt; have verifier snippets for Node, PHP, Go and Python. I'm happy to answer questions in the comments.&lt;/p&gt;

</description>
      <category>blockchain</category>
      <category>webdev</category>
      <category>backend</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>How to accept crypto payments on your own server with Docker</title>
      <dc:creator>Coldtill</dc:creator>
      <pubDate>Sun, 25 Oct 2026 11:49:14 +0000</pubDate>
      <link>https://dev.to/coldtill/how-to-accept-crypto-payments-on-your-own-server-with-docker-1b59</link>
      <guid>https://dev.to/coldtill/how-to-accept-crypto-payments-on-your-own-server-with-docker-1b59</guid>
      <description>&lt;p&gt;Most guides to taking crypto in a web shop start with "sign up for a processor". You paste an API key, the processor hosts the checkout, and the money lands in an account they control until they pay you out. That works, but it means an application, a review, a fee on every sale and a third party that can freeze the balance.&lt;/p&gt;

&lt;p&gt;I wanted the opposite: a checkout that runs on a box I own, where the coins go straight into my own wallet and nothing in the middle can hold them. That's what we built with &lt;a href="https://coldtill.com" rel="noopener noreferrer"&gt;Coldtill&lt;/a&gt;, and this post walks through how that kind of setup works and what to watch out for when you wire it into a shop.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "self-hosted" and "non-custodial" actually mean
&lt;/h2&gt;

&lt;p&gt;They get used interchangeably, but they're different promises.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosted&lt;/strong&gt; means the software runs on your infrastructure. Your server, your database, your logs. Customer data never leaves it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-custodial&lt;/strong&gt; means nobody but you controls the funds. Not the software vendor, and ideally not even the server the software runs on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second one is the hard part. A gateway can be self-hosted and still keep a hot wallet full of customer payments on the same machine that faces the internet. If someone breaks into that machine, the money goes with them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;Coldtill ships as Docker containers and runs on an ordinary Linux VPS. Two vCPU and 4 GB of RAM is comfortable, on amd64 or arm64. Under the hood it's split into separate processes that share a PostgreSQL database and never call each other directly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;api&lt;/strong&gt;: the merchant REST API, the dashboard and the hosted checkout page. This is the only part exposed to the internet, and it holds no private keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;watcher&lt;/strong&gt;: follows the chains, counts confirmations, handles reorgs and sends your webhooks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;sweeper&lt;/strong&gt;: moves funds from deposit addresses to your cold wallet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;payouts&lt;/strong&gt;: signs approved refunds and payouts from a small hot wallet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The processes that hold keys accept no network connections. So the piece an attacker can reach first has nothing worth stealing.&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fumdus8ap76t2ozie0wx1.png" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fumdus8ap76t2ozie0wx1.png" alt="Coldtill dashboard overview" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The other half of the design is where deposits go. Every customer gets their own deposit address. On EVM chains, Tron and Solana, that address is a contract that can only send to your cold wallet. Bitcoin addresses are derived from your own wallet's public key. The server can't change the destination, which means a compromised server still can't redirect a payment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The payment flow
&lt;/h2&gt;

&lt;p&gt;From your shop's point of view it's four steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your backend calls &lt;code&gt;POST /api/v1/payment&lt;/code&gt; with an amount and your order ID and gets back a checkout URL.&lt;/li&gt;
&lt;li&gt;You redirect the customer there. They pick a coin and network, scan a QR code and pay.&lt;/li&gt;
&lt;li&gt;The watcher waits for confirmations and sends your shop a signed webhook.&lt;/li&gt;
&lt;li&gt;Later, the sweeper moves the funds to your cold wallet.&lt;/li&gt;
&lt;/ol&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwnq3g4q4uk53h2lra4op.png" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwnq3g4q4uk53h2lra4op.png" alt="Coldtill checkout payment screen" width="732" height="1185"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Out of the box it handles native coins and USDC/USDT on the main chains. If you turn on auto-convert, customers can pay with 200+ coins across 35+ chains and you still receive a stablecoin, so you never end up holding a token you didn't choose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling the webhook (the part people get wrong)
&lt;/h2&gt;

&lt;p&gt;Webhooks are where shops usually get burned: they trust the JSON body, parse it before verifying, or mark an order paid twice because a delivery was retried. The rules are boring but important:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the &lt;strong&gt;raw&lt;/strong&gt; body before any JSON parsing.&lt;/li&gt;
&lt;li&gt;Reject timestamps that are too old, so a captured request can't be replayed.&lt;/li&gt;
&lt;li&gt;Recompute the HMAC over the timestamp and body and compare it in constant time.&lt;/li&gt;
&lt;li&gt;Treat events as idempotent. Retries happen, and ordering isn't guaranteed.&lt;/li&gt;
&lt;li&gt;Decide what to do from the signed body, not from an unsigned header.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's an &lt;strong&gt;illustrative&lt;/strong&gt; Express handler in TypeScript. It follows the signing scheme described in Coldtill's webhook docs, but check the docs for the current header names and payload before you copy it into production, and prefer the official SDK's verifier if you're on Node.&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="c1"&gt;// Illustrative only. See the webhook docs for the current scheme.&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&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;SECRET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COLDTILL_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;

&lt;span class="nx"&gt;app&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/coldtill/webhook&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&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;ts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-coldtill-timestamp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&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="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-coldtill-signature-v2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="c1"&gt;// 1. Reject stale or malformed timestamps (five-minute window).&lt;/span&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="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&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="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&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="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. HMAC over "&amp;lt;timestamp&amp;gt;.&amp;lt;raw body&amp;gt;", compared in constant time.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256=&lt;/span&gt;&lt;span class="dl"&gt;'&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="s1"&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="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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ts&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="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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;)&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="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&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;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&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;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;expected&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="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;ok&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&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="c1"&gt;// 3. Only now parse, and act on the signed body.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;FILLED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OVER_FILLED&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;markOrderPaid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invoice_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// must be idempotent&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&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;Make &lt;code&gt;markOrderPaid&lt;/code&gt; safe to call twice. A unique constraint on the order ID or the delivery ID is usually enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things to sort out before you take real money
&lt;/h2&gt;

&lt;p&gt;A self-hosted crypto payment gateway puts operations on you, so a short checklist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Run it on its own server&lt;/strong&gt;, not next to your shop. A hole in your storefront shouldn't reach the hot wallet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the dashboard off the public internet.&lt;/strong&gt; Put the checkout on a public subdomain like &lt;code&gt;pay.yourshop.com&lt;/code&gt; and the admin behind a VPN such as Tailscale, or at least a firewall rule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Back up the database&lt;/strong&gt; and take provider snapshots.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test on testnet first&lt;/strong&gt; and send yourself a test webhook before you flip to mainnet.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Pricing, for the curious
&lt;/h2&gt;

&lt;p&gt;The Free edition takes a 2% fee, Standard is $499 one-time with a 1% fee, and Pro is $999 one-time with no fee. Bitcoin and auto-converted payments carry no fee on any edition. There's no licence server; it keeps running even if our site goes down. The WooCommerce plugin is MIT-licensed on GitHub; the core app is proprietary under an EULA.&lt;/p&gt;

&lt;p&gt;If you want to poke around before installing anything, there's a live demo dashboard with made-up data linked from &lt;a href="https://coldtill.com" rel="noopener noreferrer"&gt;coldtill.com&lt;/a&gt;. Questions about the architecture are very welcome in the comments.&lt;/p&gt;

</description>
      <category>crypto</category>
      <category>docker</category>
      <category>selfhosted</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
